From 1e14eda50dcd4f63bc615176df686f06cfb484e0 Mon Sep 17 00:00:00 2001 From: "Builder.io" Date: Wed, 22 Jul 2026 19:00:20 +0000 Subject: [PATCH 01/28] Fix list item editing and single-click text entry in slide editor --- .../app/components/editor/SlideEditor.tsx | 33 +++++++++++++++++-- 1 file changed, 31 insertions(+), 2 deletions(-) diff --git a/templates/slides/app/components/editor/SlideEditor.tsx b/templates/slides/app/components/editor/SlideEditor.tsx index f020759347..2933312437 100644 --- a/templates/slides/app/components/editor/SlideEditor.tsx +++ b/templates/slides/app/components/editor/SlideEditor.tsx @@ -172,7 +172,20 @@ function findSmartBlock( ): HTMLElement | null { let el: HTMLElement | null = target; while (el && root.contains(el)) { - if (isTextLeaf(el)) return el; + if (isTextLeaf(el)) { + // A bullet/numbered list item must be edited together with its + // siblings so Enter can create a new
  • natively — editing a + // single
  • in isolation traps Enter inside that one item. + const parent = el.parentElement; + if ( + el.tagName === "LI" && + parent && + (parent.tagName === "UL" || parent.tagName === "OL") + ) { + return parent; + } + return el; + } // The click landed on a container (e.g. a flex wrapper around stat // rows). If that container is a smart group, use IT as the block so // the user gets multi-chunk editing of everything inside. @@ -1218,7 +1231,9 @@ export default function SlideEditor({ // intent (rich-block edit vs single-line commit) doesn't change while // they're editing the same node, so latch it. const isMultiLineLeaf = - isTextLeaf(editingEl) && RICH_BLOCK_TAGS.has(editingEl.tagName); + (isTextLeaf(editingEl) && RICH_BLOCK_TAGS.has(editingEl.tagName)) || + ((editingEl.tagName === "UL" || editingEl.tagName === "OL") && + isSmartGroup(editingEl)); const onKey = (e: KeyboardEvent) => { if (e.key === "Escape") { e.preventDefault(); @@ -1750,6 +1765,17 @@ export default function SlideEditor({ showImageOverlay(target); + // A single click directly on text enters edit mode immediately — + // matches the double-click behavior so highlighting text and using + // formatting shortcuts works without an extra click first. + if (!readOnly && isHtmlSlide && slideContent) { + const block = findSmartBlock(target, slideContent); + if (block) { + enterInlineEdit(block); + return; + } + } + // Send style-editing postMessage with a unique selector for the clicked element const selectableEl = slideContent ? findSelectableElement(target, slideContent) @@ -1772,6 +1798,9 @@ export default function SlideEditor({ clearMultiSelection, clearSelectedElement, selectElementForStyling, + readOnly, + isHtmlSlide, + enterInlineEdit, ], ); From 966cf59dd9dc1575ab59e96e546d45584e3a2451 Mon Sep 17 00:00:00 2001 From: "Builder.io" Date: Wed, 22 Jul 2026 22:48:05 +0000 Subject: [PATCH 02/28] Fix slides text editing and formatting menu issues --- .../app/components/editor/SlideEditor.tsx | 23 +++++++++---------- 1 file changed, 11 insertions(+), 12 deletions(-) diff --git a/templates/slides/app/components/editor/SlideEditor.tsx b/templates/slides/app/components/editor/SlideEditor.tsx index 2933312437..5735aeb2d7 100644 --- a/templates/slides/app/components/editor/SlideEditor.tsx +++ b/templates/slides/app/components/editor/SlideEditor.tsx @@ -1235,6 +1235,7 @@ export default function SlideEditor({ ((editingEl.tagName === "UL" || editingEl.tagName === "OL") && isSmartGroup(editingEl)); const onKey = (e: KeyboardEvent) => { + if (e.target instanceof Node && !editingEl.contains(e.target)) return; if (e.key === "Escape") { e.preventDefault(); e.stopPropagation(); @@ -1765,18 +1766,8 @@ export default function SlideEditor({ showImageOverlay(target); - // A single click directly on text enters edit mode immediately — - // matches the double-click behavior so highlighting text and using - // formatting shortcuts works without an extra click first. - if (!readOnly && isHtmlSlide && slideContent) { - const block = findSmartBlock(target, slideContent); - if (block) { - enterInlineEdit(block); - return; - } - } - - // Send style-editing postMessage with a unique selector for the clicked element + // Select the clicked element for styling first so the style dock + // (font size, color, weight, …) targets what was clicked. const selectableEl = slideContent ? findSelectableElement(target, slideContent) : null; @@ -1785,6 +1776,14 @@ export default function SlideEditor({ selectElementForStyling(selectableEl, selector); enterSelectionMode("agentNative.enterStyleEditing", { selector }); } + + // Then, for text blocks, also enter inline edit on a single click so + // highlighting text, the formatting bubble menu, and shortcuts like + // Cmd+B work immediately — matching the double-click behavior. + if (!readOnly && isHtmlSlide && slideContent) { + const block = findSmartBlock(target, slideContent); + if (block) enterInlineEdit(block); + } }, [ showImageOverlay, From fc39b0bda8a274863a388c55f1e3784cf57e4c97 Mon Sep 17 00:00:00 2001 From: "Builder.io" Date: Wed, 22 Jul 2026 22:58:19 +0000 Subject: [PATCH 03/28] Fix Enter key to create new bullets in styled list items --- .../app/components/editor/SlideEditor.tsx | 174 ++++++++++++++++-- 1 file changed, 160 insertions(+), 14 deletions(-) diff --git a/templates/slides/app/components/editor/SlideEditor.tsx b/templates/slides/app/components/editor/SlideEditor.tsx index 5735aeb2d7..3484aed650 100644 --- a/templates/slides/app/components/editor/SlideEditor.tsx +++ b/templates/slides/app/components/editor/SlideEditor.tsx @@ -152,6 +152,146 @@ function isSmartGroup(el: HTMLElement): boolean { return true; } +/** Single glyphs commonly used as bullet markers in styled (non-
      ) lists. */ +const BULLET_GLYPHS = new Set([ + "\u2022", // • + "\u25CF", // ● + "\u25E6", // ◦ + "\u25AA", // ▪ + "\u2023", // ‣ + "\u00B7", // · + "\u2043", // ⁃ + "-", + "\u2013", // – + "\u2014", // — + "*", +]); + +/** True if an element is just a bullet marker glyph (e.g. a leading ● span). */ +function isBulletMarker(el: Element): boolean { + const text = (el.textContent ?? "").trim(); + return text.length > 0 && [...text].every((c) => BULLET_GLYPHS.has(c)); +} + +/** + * A "bullet row" is a styled list item built from a marker glyph plus text + * (e.g. `
      Point
      `) rather than a real + *
    • . Its first element child is a marker glyph and it carries other text. + */ +function isBulletRow(el: HTMLElement): boolean { + if (el.tagName !== "DIV" && el.tagName !== "LI" && el.tagName !== "P") { + return false; + } + const kids = Array.from(el.children); + if (kids.length < 2) return false; + if (!isBulletMarker(kids[0])) return false; + const rest = kids + .slice(1) + .map((k) => k.textContent ?? "") + .join("") + .trim(); + return rest.length > 0; +} + +/** A container whose element children are all styled bullet rows. */ +function isBulletList(el: HTMLElement): boolean { + const kids = Array.from(el.children); + if (kids.length === 0) return false; + return kids.every((k) => isBulletRow(k as HTMLElement)); +} + +/** + * Walk up from a text leaf to the nearest enclosing list container — either a + * native
        /
          or a styled bullet-row container — so Enter can add a new + * item to the whole list instead of being trapped inside one item. + */ +function findEnclosingList(el: HTMLElement, root: HTMLElement): HTMLElement | null { + let node: HTMLElement | null = el; + while (node && root.contains(node)) { + const parent = node.parentElement; + if (!parent) break; + if ( + node.tagName === "LI" && + (parent.tagName === "UL" || parent.tagName === "OL") + ) { + return parent; + } + if (isBulletRow(node) && isBulletList(parent)) return parent; + node = parent; + } + return null; +} + +/** Set the text of a list row, preserving a leading bullet marker span. */ +function setRowText(row: HTMLElement, text: string): void { + const spans = Array.from(row.children).filter( + (c) => c.tagName === "SPAN", + ) as HTMLElement[]; + const textSpans = spans.filter((s) => !isBulletMarker(s)); + if (textSpans.length > 0) { + textSpans[0].textContent = text; + for (let i = 1; i < textSpans.length; i++) textSpans[i].textContent = ""; + } else { + row.textContent = text; + } +} + +/** Place the caret at the start of a list row's editable text. */ +function focusRowStart(row: HTMLElement): void { + const spans = Array.from(row.children).filter( + (c) => c.tagName === "SPAN", + ) as HTMLElement[]; + const target: Node = spans.find((s) => !isBulletMarker(s)) ?? row; + const sel = window.getSelection(); + if (!sel) return; + const range = document.createRange(); + range.selectNodeContents(target); + range.collapse(true); + sel.removeAllRanges(); + sel.addRange(range); +} + +/** + * Insert a new list item after the caret's current row. Text after the caret + * moves into the new row; the marker glyph is preserved. Returns false when + * the caret isn't inside a direct row of the list so the caller can fall back. + */ +function insertBulletAfterCaret(list: HTMLElement): boolean { + const sel = window.getSelection(); + if (!sel || sel.rangeCount === 0) return false; + const range = sel.getRangeAt(0); + if (!range.collapsed) range.deleteContents(); + + let row: HTMLElement | null = null; + let node: Node | null = range.endContainer; + while (node && node !== list) { + if (node.parentNode === list) { + row = node as HTMLElement; + break; + } + node = node.parentNode; + } + if (!row) return false; + + let tail = ""; + const caretNode = range.endContainer; + if (caretNode.nodeType === Node.TEXT_NODE) { + const full = caretNode.textContent ?? ""; + tail = full.slice(range.endOffset); + caretNode.textContent = full.slice(0, range.endOffset); + } + + const newRow = row.cloneNode(true) as HTMLElement; + for (const node of [newRow, ...Array.from(newRow.querySelectorAll("*"))]) { + node.removeAttribute("data-builder-id"); + node.removeAttribute("data-fusion-element-id"); + } + setRowText(newRow, tail); + row.after(newRow); + focusRowStart(newRow); + return true; +} + /** * Find the "smart block" to edit for a given click target. A smart block is * either: @@ -173,17 +313,11 @@ function findSmartBlock( let el: HTMLElement | null = target; while (el && root.contains(el)) { if (isTextLeaf(el)) { - // A bullet/numbered list item must be edited together with its - // siblings so Enter can create a new
        1. natively — editing a - // single
        2. in isolation traps Enter inside that one item. - const parent = el.parentElement; - if ( - el.tagName === "LI" && - parent && - (parent.tagName === "UL" || parent.tagName === "OL") - ) { - return parent; - } + // A list item (native
        3. or a styled bullet row) must be edited + // together with its siblings so Enter can add a new item — editing a + // single item in isolation traps Enter inside that one row. + const list = findEnclosingList(el, root); + if (list) return list; return el; } // The click landed on a container (e.g. a flex wrapper around stat @@ -1230,10 +1364,13 @@ export default function SlideEditor({ // mid-edit and incorrectly commit the user out of the block. The user's // intent (rich-block edit vs single-line commit) doesn't change while // they're editing the same node, so latch it. - const isMultiLineLeaf = - (isTextLeaf(editingEl) && RICH_BLOCK_TAGS.has(editingEl.tagName)) || + const editingBulletList = + isBulletList(editingEl) || ((editingEl.tagName === "UL" || editingEl.tagName === "OL") && isSmartGroup(editingEl)); + const isMultiLineLeaf = + (isTextLeaf(editingEl) && RICH_BLOCK_TAGS.has(editingEl.tagName)) || + editingBulletList; const onKey = (e: KeyboardEvent) => { if (e.target instanceof Node && !editingEl.contains(e.target)) return; if (e.key === "Escape") { @@ -1245,12 +1382,21 @@ export default function SlideEditor({ if (e.key === "Enter") { // Smart Enter: // - Shift+Enter always inserts a
          . + // - Inside a styled bullet list, Enter clones the current row so a + // new bullet (marker + empty text) appears — contentEditable's + // native split can't recreate the marker glyph. // - A single

          or

          leaf is multi-line capable — Enter // creates a new line via contentEditable's default behavior. // - Headings, inline leaves, and smart groups commit on Enter // so the slide layout can never be broken by a stray new node. if (e.shiftKey) return; + if (editingBulletList && insertBulletAfterCaret(editingEl)) { + e.preventDefault(); + captureInlineEditDraft(slide.id); + return; + } + if (!isMultiLineLeaf) { e.preventDefault(); exitInlineEdit(); @@ -1259,7 +1405,7 @@ export default function SlideEditor({ }; window.addEventListener("keydown", onKey, true); return () => window.removeEventListener("keydown", onKey, true); - }, [exitInlineEdit, editingEl]); + }, [exitInlineEdit, editingEl, captureInlineEditDraft, slide.id]); // Click-outside: exit inline edit mode useEffect(() => { From def7673c21a74623834449f19bb9ed563eb257d8 Mon Sep 17 00:00:00 2001 From: "Builder.io" Date: Wed, 22 Jul 2026 22:59:16 +0000 Subject: [PATCH 04/28] Fix formatting in SlideEditor component --- templates/slides/app/components/editor/SlideEditor.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/templates/slides/app/components/editor/SlideEditor.tsx b/templates/slides/app/components/editor/SlideEditor.tsx index 3484aed650..1ae2594b01 100644 --- a/templates/slides/app/components/editor/SlideEditor.tsx +++ b/templates/slides/app/components/editor/SlideEditor.tsx @@ -205,7 +205,10 @@ function isBulletList(el: HTMLElement): boolean { * native
            /
              or a styled bullet-row container — so Enter can add a new * item to the whole list instead of being trapped inside one item. */ -function findEnclosingList(el: HTMLElement, root: HTMLElement): HTMLElement | null { +function findEnclosingList( + el: HTMLElement, + root: HTMLElement, +): HTMLElement | null { let node: HTMLElement | null = el; while (node && root.contains(node)) { const parent = node.parentElement; From 7e357fc6fb0701b9c42a4baa0dc1c85a5ae01e25 Mon Sep 17 00:00:00 2001 From: "Builder.io" Date: Wed, 22 Jul 2026 23:13:14 +0000 Subject: [PATCH 05/28] Fix bullet point font size when creating new list items --- .../app/components/editor/SlideEditor.tsx | 47 ++++++++++--------- 1 file changed, 24 insertions(+), 23 deletions(-) diff --git a/templates/slides/app/components/editor/SlideEditor.tsx b/templates/slides/app/components/editor/SlideEditor.tsx index 1ae2594b01..ac5a122460 100644 --- a/templates/slides/app/components/editor/SlideEditor.tsx +++ b/templates/slides/app/components/editor/SlideEditor.tsx @@ -225,30 +225,30 @@ function findEnclosingList( return null; } -/** Set the text of a list row, preserving a leading bullet marker span. */ -function setRowText(row: HTMLElement, text: string): void { - const spans = Array.from(row.children).filter( - (c) => c.tagName === "SPAN", - ) as HTMLElement[]; - const textSpans = spans.filter((s) => !isBulletMarker(s)); - if (textSpans.length > 0) { - textSpans[0].textContent = text; - for (let i = 1; i < textSpans.length; i++) textSpans[i].textContent = ""; - } else { - row.textContent = text; - } -} +/** Zero-width space: keeps the caret inside an otherwise-empty text span so + * typed characters inherit that span's font instead of the container's. */ +const ZERO_WIDTH_SPACE = "\u200B"; -/** Place the caret at the start of a list row's editable text. */ -function focusRowStart(row: HTMLElement): void { +/** + * Seed a freshly-inserted row with the given tail text and place the caret at + * the start of its editable text. The text is written into a real text node + * inside the row's non-marker text span so typed characters inherit the row's + * font size (an empty inline span would drop the caret to the container). + */ +function primeNewRow(row: HTMLElement, tail: string): void { const spans = Array.from(row.children).filter( (c) => c.tagName === "SPAN", ) as HTMLElement[]; - const target: Node = spans.find((s) => !isBulletMarker(s)) ?? row; + const textSpan = spans.find((s) => !isBulletMarker(s)); + const target: HTMLElement = textSpan ?? row; + const initial = tail.length > 0 ? tail : ZERO_WIDTH_SPACE; + const textNode = document.createTextNode(initial); + target.replaceChildren(textNode); + const sel = window.getSelection(); if (!sel) return; const range = document.createRange(); - range.selectNodeContents(target); + range.setStart(textNode, tail.length > 0 ? 0 : ZERO_WIDTH_SPACE.length); range.collapse(true); sel.removeAllRanges(); sel.addRange(range); @@ -285,13 +285,12 @@ function insertBulletAfterCaret(list: HTMLElement): boolean { } const newRow = row.cloneNode(true) as HTMLElement; - for (const node of [newRow, ...Array.from(newRow.querySelectorAll("*"))]) { - node.removeAttribute("data-builder-id"); - node.removeAttribute("data-fusion-element-id"); + for (const el of [newRow, ...Array.from(newRow.querySelectorAll("*"))]) { + el.removeAttribute("data-builder-id"); + el.removeAttribute("data-fusion-element-id"); } - setRowText(newRow, tail); row.after(newRow); - focusRowStart(newRow); + primeNewRow(newRow, tail); return true; } @@ -354,7 +353,9 @@ function stripBuilderIds(html: string): string { doc.querySelector("[data-strip-root]")?.innerHTML ?? doc.body.innerHTML; } - return cleaned.replace(/\s*data-builder-id="[^"]*"/g, ""); + return cleaned + .replace(/\s*data-builder-id="[^"]*"/g, "") + .replace(/\u200B/g, ""); } function cssPx(value: string): number { From ff0d379ab14ece69ca0cbd5953caac7c4eb5ca0d Mon Sep 17 00:00:00 2001 From: "Builder.io" Date: Wed, 22 Jul 2026 23:40:09 +0000 Subject: [PATCH 06/28] Fix bullet point creation on Enter key in slide editor --- .../app/components/editor/SlideEditor.tsx | 8 +- .../components/editor/bullet-editing.test.tsx | 91 +++++++++++++++++++ 2 files changed, 95 insertions(+), 4 deletions(-) create mode 100644 templates/slides/app/components/editor/bullet-editing.test.tsx diff --git a/templates/slides/app/components/editor/SlideEditor.tsx b/templates/slides/app/components/editor/SlideEditor.tsx index ac5a122460..8c796ff6a9 100644 --- a/templates/slides/app/components/editor/SlideEditor.tsx +++ b/templates/slides/app/components/editor/SlideEditor.tsx @@ -178,7 +178,7 @@ function isBulletMarker(el: Element): boolean { * (e.g. `
              Point
              `) rather than a real *
            1. . Its first element child is a marker glyph and it carries other text. */ -function isBulletRow(el: HTMLElement): boolean { +export function isBulletRow(el: HTMLElement): boolean { if (el.tagName !== "DIV" && el.tagName !== "LI" && el.tagName !== "P") { return false; } @@ -194,7 +194,7 @@ function isBulletRow(el: HTMLElement): boolean { } /** A container whose element children are all styled bullet rows. */ -function isBulletList(el: HTMLElement): boolean { +export function isBulletList(el: HTMLElement): boolean { const kids = Array.from(el.children); if (kids.length === 0) return false; return kids.every((k) => isBulletRow(k as HTMLElement)); @@ -205,7 +205,7 @@ function isBulletList(el: HTMLElement): boolean { * native
                /
                  or a styled bullet-row container — so Enter can add a new * item to the whole list instead of being trapped inside one item. */ -function findEnclosingList( +export function findEnclosingList( el: HTMLElement, root: HTMLElement, ): HTMLElement | null { @@ -259,7 +259,7 @@ function primeNewRow(row: HTMLElement, tail: string): void { * moves into the new row; the marker glyph is preserved. Returns false when * the caret isn't inside a direct row of the list so the caller can fall back. */ -function insertBulletAfterCaret(list: HTMLElement): boolean { +export function insertBulletAfterCaret(list: HTMLElement): boolean { const sel = window.getSelection(); if (!sel || sel.rangeCount === 0) return false; const range = sel.getRangeAt(0); diff --git a/templates/slides/app/components/editor/bullet-editing.test.tsx b/templates/slides/app/components/editor/bullet-editing.test.tsx new file mode 100644 index 0000000000..e231def8cb --- /dev/null +++ b/templates/slides/app/components/editor/bullet-editing.test.tsx @@ -0,0 +1,91 @@ +// @vitest-environment happy-dom +import { beforeEach, describe, expect, it } from "vitest"; + +import { + findEnclosingList, + insertBulletAfterCaret, + isBulletList, + isBulletRow, +} from "@/components/editor/SlideEditor"; + +const LIST_HTML = `
                  +
                  First point
                  +
                  Second point
                  +
                  Third point
                  +
                  `; + +function setup() { + document.body.innerHTML = LIST_HTML; + const root = document.querySelector(".slide-content") as HTMLElement; + const list = document.querySelector(".bullets") as HTMLElement; + return { root, list }; +} + +function caretAtEndOf(textSpan: HTMLElement) { + const textNode = textSpan.firstChild as Text; + const sel = window.getSelection(); + const range = document.createRange(); + range.setStart(textNode, textNode.length); + range.collapse(true); + sel?.removeAllRanges(); + sel?.addRange(range); +} + +describe("styled bullet editing", () => { + beforeEach(() => { + document.body.innerHTML = ""; + }); + + it("recognizes styled bullet rows and lists", () => { + const { list } = setup(); + expect(isBulletList(list)).toBe(true); + for (const row of Array.from(list.children)) { + expect(isBulletRow(row as HTMLElement)).toBe(true); + } + }); + + it("resolves a bullet text span to its list container", () => { + const { root, list } = setup(); + const thirdText = list.children[2].children[1] as HTMLElement; + expect(findEnclosingList(thirdText, root)).toBe(list); + }); + + it("adds a new row when Enter is pressed at the end of a bullet", () => { + const { list } = setup(); + const thirdText = list.children[2].children[1] as HTMLElement; + caretAtEndOf(thirdText); + + expect(list.children.length).toBe(3); + const created = insertBulletAfterCaret(list); + expect(created).toBe(true); + expect(list.children.length).toBe(4); + + const newRow = list.children[3] as HTMLElement; + // Marker preserved, text emptied (zero-width space placeholder only). + expect(newRow.children[0].textContent).toBe("\u25CF"); + expect((newRow.children[1].textContent ?? "").replace(/\u200B/g, "")).toBe( + "", + ); + // New row keeps the 22px font styling so typed text matches siblings. + expect((newRow as HTMLElement).style.fontSize).toBe("22px"); + }); + + it("splits text after the caret into the new bullet", () => { + const { list } = setup(); + const secondText = list.children[1].children[1] as HTMLElement; + const textNode = secondText.firstChild as Text; + // Caret after "Second" (before " point"). + const sel = window.getSelection(); + const range = document.createRange(); + range.setStart(textNode, "Second".length); + range.collapse(true); + sel?.removeAllRanges(); + sel?.addRange(range); + + insertBulletAfterCaret(list); + expect(list.children.length).toBe(4); + expect(secondText.textContent).toBe("Second"); + const newRow = list.children[2] as HTMLElement; + expect(newRow.children[1].textContent).toBe(" point"); + }); +}); From 75dc7dae38af2abda9a222280545197a8447d335 Mon Sep 17 00:00:00 2001 From: "Builder.io" Date: Thu, 23 Jul 2026 00:15:42 +0000 Subject: [PATCH 07/28] Fix text cursor visibility and line editing in slide editor --- .../app/components/editor/SlideEditor.tsx | 178 +++--------------- .../components/editor/bullet-editing.test.tsx | 68 ++++--- .../app/components/editor/bullet-editing.ts | 169 +++++++++++++++++ 3 files changed, 242 insertions(+), 173 deletions(-) create mode 100644 templates/slides/app/components/editor/bullet-editing.ts diff --git a/templates/slides/app/components/editor/SlideEditor.tsx b/templates/slides/app/components/editor/SlideEditor.tsx index 8c796ff6a9..21609b1a27 100644 --- a/templates/slides/app/components/editor/SlideEditor.tsx +++ b/templates/slides/app/components/editor/SlideEditor.tsx @@ -35,6 +35,11 @@ import { createPortal } from "react-dom"; import { ExcalidrawSlide } from "@/components/deck/ExcalidrawSlide"; import SlideRenderer from "@/components/deck/SlideRenderer"; import type { SlideOverflowInfo } from "@/components/deck/SlideRenderer"; +import { + findEnclosingList, + insertBulletAfterCaret, + isBulletList, +} from "@/components/editor/bullet-editing"; import { Button } from "@/components/ui/button"; import { Tooltip, @@ -152,148 +157,6 @@ function isSmartGroup(el: HTMLElement): boolean { return true; } -/** Single glyphs commonly used as bullet markers in styled (non-
                    ) lists. */ -const BULLET_GLYPHS = new Set([ - "\u2022", // • - "\u25CF", // ● - "\u25E6", // ◦ - "\u25AA", // ▪ - "\u2023", // ‣ - "\u00B7", // · - "\u2043", // ⁃ - "-", - "\u2013", // – - "\u2014", // — - "*", -]); - -/** True if an element is just a bullet marker glyph (e.g. a leading ● span). */ -function isBulletMarker(el: Element): boolean { - const text = (el.textContent ?? "").trim(); - return text.length > 0 && [...text].every((c) => BULLET_GLYPHS.has(c)); -} - -/** - * A "bullet row" is a styled list item built from a marker glyph plus text - * (e.g. `
                    Point
                    `) rather than a real - *
                  • . Its first element child is a marker glyph and it carries other text. - */ -export function isBulletRow(el: HTMLElement): boolean { - if (el.tagName !== "DIV" && el.tagName !== "LI" && el.tagName !== "P") { - return false; - } - const kids = Array.from(el.children); - if (kids.length < 2) return false; - if (!isBulletMarker(kids[0])) return false; - const rest = kids - .slice(1) - .map((k) => k.textContent ?? "") - .join("") - .trim(); - return rest.length > 0; -} - -/** A container whose element children are all styled bullet rows. */ -export function isBulletList(el: HTMLElement): boolean { - const kids = Array.from(el.children); - if (kids.length === 0) return false; - return kids.every((k) => isBulletRow(k as HTMLElement)); -} - -/** - * Walk up from a text leaf to the nearest enclosing list container — either a - * native
                      /
                        or a styled bullet-row container — so Enter can add a new - * item to the whole list instead of being trapped inside one item. - */ -export function findEnclosingList( - el: HTMLElement, - root: HTMLElement, -): HTMLElement | null { - let node: HTMLElement | null = el; - while (node && root.contains(node)) { - const parent = node.parentElement; - if (!parent) break; - if ( - node.tagName === "LI" && - (parent.tagName === "UL" || parent.tagName === "OL") - ) { - return parent; - } - if (isBulletRow(node) && isBulletList(parent)) return parent; - node = parent; - } - return null; -} - -/** Zero-width space: keeps the caret inside an otherwise-empty text span so - * typed characters inherit that span's font instead of the container's. */ -const ZERO_WIDTH_SPACE = "\u200B"; - -/** - * Seed a freshly-inserted row with the given tail text and place the caret at - * the start of its editable text. The text is written into a real text node - * inside the row's non-marker text span so typed characters inherit the row's - * font size (an empty inline span would drop the caret to the container). - */ -function primeNewRow(row: HTMLElement, tail: string): void { - const spans = Array.from(row.children).filter( - (c) => c.tagName === "SPAN", - ) as HTMLElement[]; - const textSpan = spans.find((s) => !isBulletMarker(s)); - const target: HTMLElement = textSpan ?? row; - const initial = tail.length > 0 ? tail : ZERO_WIDTH_SPACE; - const textNode = document.createTextNode(initial); - target.replaceChildren(textNode); - - const sel = window.getSelection(); - if (!sel) return; - const range = document.createRange(); - range.setStart(textNode, tail.length > 0 ? 0 : ZERO_WIDTH_SPACE.length); - range.collapse(true); - sel.removeAllRanges(); - sel.addRange(range); -} - -/** - * Insert a new list item after the caret's current row. Text after the caret - * moves into the new row; the marker glyph is preserved. Returns false when - * the caret isn't inside a direct row of the list so the caller can fall back. - */ -export function insertBulletAfterCaret(list: HTMLElement): boolean { - const sel = window.getSelection(); - if (!sel || sel.rangeCount === 0) return false; - const range = sel.getRangeAt(0); - if (!range.collapsed) range.deleteContents(); - - let row: HTMLElement | null = null; - let node: Node | null = range.endContainer; - while (node && node !== list) { - if (node.parentNode === list) { - row = node as HTMLElement; - break; - } - node = node.parentNode; - } - if (!row) return false; - - let tail = ""; - const caretNode = range.endContainer; - if (caretNode.nodeType === Node.TEXT_NODE) { - const full = caretNode.textContent ?? ""; - tail = full.slice(range.endOffset); - caretNode.textContent = full.slice(0, range.endOffset); - } - - const newRow = row.cloneNode(true) as HTMLElement; - for (const el of [newRow, ...Array.from(newRow.querySelectorAll("*"))]) { - el.removeAttribute("data-builder-id"); - el.removeAttribute("data-fusion-element-id"); - } - row.after(newRow); - primeNewRow(newRow, tail); - return true; -} - /** * Find the "smart block" to edit for a given click target. A smart block is * either: @@ -1916,8 +1779,27 @@ export default function SlideEditor({ showImageOverlay(target); - // Select the clicked element for styling first so the style dock - // (font size, color, weight, …) targets what was clicked. + // For editable text, a single click edits the whole smart block (a text + // leaf, or an entire bullet list) — not the individual line — so typing, + // highlighting, shortcuts, and Enter-to-add-bullet all work, and the + // style dock targets the same block being edited. + if (!readOnly && isHtmlSlide && slideContent) { + const block = findSmartBlock(target, slideContent); + if (block) { + const blockSelector = getBuilderSelector(block); + if (blockSelector) { + selectElementForStyling(block, blockSelector); + enterSelectionMode("agentNative.enterStyleEditing", { + selector: blockSelector, + }); + } + enterInlineEdit(block); + return; + } + } + + // Non-text elements (images, shapes, …): select the clicked element for + // styling. const selectableEl = slideContent ? findSelectableElement(target, slideContent) : null; @@ -1926,14 +1808,6 @@ export default function SlideEditor({ selectElementForStyling(selectableEl, selector); enterSelectionMode("agentNative.enterStyleEditing", { selector }); } - - // Then, for text blocks, also enter inline edit on a single click so - // highlighting text, the formatting bubble menu, and shortcuts like - // Cmd+B work immediately — matching the double-click behavior. - if (!readOnly && isHtmlSlide && slideContent) { - const block = findSmartBlock(target, slideContent); - if (block) enterInlineEdit(block); - } }, [ showImageOverlay, diff --git a/templates/slides/app/components/editor/bullet-editing.test.tsx b/templates/slides/app/components/editor/bullet-editing.test.tsx index e231def8cb..66ebec3752 100644 --- a/templates/slides/app/components/editor/bullet-editing.test.tsx +++ b/templates/slides/app/components/editor/bullet-editing.test.tsx @@ -6,12 +6,15 @@ import { insertBulletAfterCaret, isBulletList, isBulletRow, -} from "@/components/editor/SlideEditor"; +} from "@/components/editor/bullet-editing"; + +const row = (text: string) => + `
                        ${text}
                        `; const LIST_HTML = `
                        -
                        First point
                        -
                        Second point
                        -
                        Third point
                        + ${row("First point")} + ${row("Second point")} + ${row("Third point")}
                        `; function setup() { @@ -21,11 +24,10 @@ function setup() { return { root, list }; } -function caretAtEndOf(textSpan: HTMLElement) { - const textNode = textSpan.firstChild as Text; +function placeCaret(node: Node, offset: number) { const sel = window.getSelection(); const range = document.createRange(); - range.setStart(textNode, textNode.length); + range.setStart(node, offset); range.collapse(true); sel?.removeAllRanges(); sel?.addRange(range); @@ -39,8 +41,8 @@ describe("styled bullet editing", () => { it("recognizes styled bullet rows and lists", () => { const { list } = setup(); expect(isBulletList(list)).toBe(true); - for (const row of Array.from(list.children)) { - expect(isBulletRow(row as HTMLElement)).toBe(true); + for (const r of Array.from(list.children)) { + expect(isBulletRow(r as HTMLElement)).toBe(true); } }); @@ -53,20 +55,18 @@ describe("styled bullet editing", () => { it("adds a new row when Enter is pressed at the end of a bullet", () => { const { list } = setup(); const thirdText = list.children[2].children[1] as HTMLElement; - caretAtEndOf(thirdText); + const textNode = thirdText.firstChild as Text; + placeCaret(textNode, textNode.length); expect(list.children.length).toBe(3); - const created = insertBulletAfterCaret(list); - expect(created).toBe(true); + expect(insertBulletAfterCaret(list)).toBe(true); expect(list.children.length).toBe(4); const newRow = list.children[3] as HTMLElement; - // Marker preserved, text emptied (zero-width space placeholder only). expect(newRow.children[0].textContent).toBe("\u25CF"); expect((newRow.children[1].textContent ?? "").replace(/\u200B/g, "")).toBe( "", ); - // New row keeps the 22px font styling so typed text matches siblings. expect((newRow as HTMLElement).style.fontSize).toBe("22px"); }); @@ -74,13 +74,7 @@ describe("styled bullet editing", () => { const { list } = setup(); const secondText = list.children[1].children[1] as HTMLElement; const textNode = secondText.firstChild as Text; - // Caret after "Second" (before " point"). - const sel = window.getSelection(); - const range = document.createRange(); - range.setStart(textNode, "Second".length); - range.collapse(true); - sel?.removeAllRanges(); - sel?.addRange(range); + placeCaret(textNode, "Second".length); insertBulletAfterCaret(list); expect(list.children.length).toBe(4); @@ -88,4 +82,36 @@ describe("styled bullet editing", () => { const newRow = list.children[2] as HTMLElement; expect(newRow.children[1].textContent).toBe(" point"); }); + + it("recognizes a list when a row's text is a bare text node", () => { + document.body.innerHTML = + '
                        ' + + '
                        \u25CFFirst point
                        ' + + '
                        \u25CFSecond point
                        ' + + "
                        "; + const root = document.querySelector(".slide-content") as HTMLElement; + const list = document.querySelector(".bullets") as HTMLElement; + expect(isBulletList(list)).toBe(true); + + const second = list.children[1] as HTMLElement; + const bareText = second.childNodes[1] as Text; + expect(findEnclosingList(second, root)).toBe(list); + + placeCaret(bareText, bareText.length); + expect(insertBulletAfterCaret(list)).toBe(true); + expect(list.children.length).toBe(3); + const newRow = list.children[2] as HTMLElement; + expect(newRow.textContent?.includes("\u25CF")).toBe(true); + }); + + it("tolerates one stray non-bullet child in the list", () => { + document.body.innerHTML = + '
                        ' + + "
                        \u25CFFirst point
                        " + + "
                        \u25CFSecond point
                        " + + "
                        " + + "
                        "; + const list = document.querySelector(".bullets") as HTMLElement; + expect(isBulletList(list)).toBe(true); + }); }); diff --git a/templates/slides/app/components/editor/bullet-editing.ts b/templates/slides/app/components/editor/bullet-editing.ts new file mode 100644 index 0000000000..33d4c65435 --- /dev/null +++ b/templates/slides/app/components/editor/bullet-editing.ts @@ -0,0 +1,169 @@ +/** + * Helpers for editing styled bullet lists — list-item rows built from a marker + * glyph plus text (e.g. `
                        Point
                        `) rather + * than real
                          /
                        • markup, which is how generated decks represent bullets. + * + * Kept in a standalone module (no React exports) so SlideEditor stays + * Fast-Refresh friendly and this logic is unit-testable. + */ + +/** Zero-width space: keeps the caret inside an otherwise-empty text span so + * typed characters inherit that span's font instead of the container's. */ +export const ZERO_WIDTH_SPACE = "\u200B"; + +/** Single glyphs commonly used as bullet markers in styled (non-
                            ) lists. */ +const BULLET_GLYPHS = new Set([ + "\u2022", // • + "\u25CF", // ● + "\u25E6", // ◦ + "\u25AA", // ▪ + "\u2023", // ‣ + "\u00B7", // · + "\u2043", // ⁃ + "-", + "\u2013", // – + "\u2014", // — + "*", +]); + +/** True if an element is just a bullet marker glyph (e.g. a leading ● span). */ +export function isBulletMarker(el: Element): boolean { + const text = (el.textContent ?? "").trim(); + return text.length > 0 && [...text].every((c) => BULLET_GLYPHS.has(c)); +} + +/** + * A "bullet row" is a styled list item whose first element child is a marker + * glyph. The text after it may be a or a bare text node (contentEditable + * often unwraps spans while editing), and may be empty for a freshly-added + * bullet — so only the leading marker is required. + */ +export function isBulletRow(el: HTMLElement): boolean { + if (el.tagName !== "DIV" && el.tagName !== "LI" && el.tagName !== "P") { + return false; + } + const first = el.firstElementChild; + return !!first && isBulletMarker(first); +} + +/** Count the styled bullet rows directly inside a container. */ +export function bulletRowCount(el: HTMLElement): number { + return Array.from(el.children).filter((k) => isBulletRow(k as HTMLElement)) + .length; +} + +/** A container whose element children are (essentially) all styled bullet rows. */ +export function isBulletList(el: HTMLElement): boolean { + const kids = Array.from(el.children); + if (kids.length === 0) return false; + const rows = bulletRowCount(el); + // Tolerate one stray non-bullet child (e.g. a stray
                            /
                            left behind by + // contentEditable) as long as the container is clearly a bullet list. + return rows >= 1 && rows >= kids.length - 1; +} + +/** + * Walk up from a text leaf to the nearest enclosing list container — either a + * native
                              /
                                or a styled bullet-row container — so Enter can add a new + * item to the whole list instead of being trapped inside one item. + */ +export function findEnclosingList( + el: HTMLElement, + root: HTMLElement, +): HTMLElement | null { + let node: HTMLElement | null = el; + while (node && root.contains(node)) { + const parent = node.parentElement; + if (!parent) break; + if ( + node.tagName === "LI" && + (parent.tagName === "UL" || parent.tagName === "OL") + ) { + return parent; + } + if (isBulletRow(node) && isBulletList(parent)) return parent; + // Even from a bullet row whose siblings aren't all bullets, treat the + // parent as a list once it holds two or more bullet rows. + if (isBulletRow(node) && bulletRowCount(parent) >= 2) return parent; + node = parent; + } + return null; +} + +/** + * Seed a freshly-inserted row with the given tail text and place the caret at + * the start of its editable text. The text is written into a real text node + * inside the row's non-marker text span so typed characters inherit the row's + * font size (an empty inline span would drop the caret to the container). + */ +function primeNewRow(row: HTMLElement, tail: string): void { + const marker = + row.firstElementChild && isBulletMarker(row.firstElementChild) + ? (row.firstElementChild as HTMLElement) + : null; + const textSpan = Array.from(row.children).find( + (c) => c.tagName === "SPAN" && c !== marker && !isBulletMarker(c), + ) as HTMLElement | undefined; + + const initial = tail.length > 0 ? tail : ZERO_WIDTH_SPACE; + const textNode = document.createTextNode(initial); + + if (textSpan) { + // Reset only the text span; the marker span is a sibling and is preserved. + textSpan.replaceChildren(textNode); + } else { + // No dedicated text span (text is a bare node): keep the marker, drop + // everything after it, then append the fresh text so it inherits the row. + while (marker?.nextSibling) marker.nextSibling.remove(); + if (!marker) row.replaceChildren(); + row.appendChild(textNode); + } + + const sel = window.getSelection(); + if (!sel) return; + const range = document.createRange(); + range.setStart(textNode, tail.length > 0 ? 0 : ZERO_WIDTH_SPACE.length); + range.collapse(true); + sel.removeAllRanges(); + sel.addRange(range); +} + +/** + * Insert a new list item after the caret's current row. Text after the caret + * moves into the new row; the marker glyph is preserved. Returns false when + * the caret isn't inside a direct row of the list so the caller can fall back. + */ +export function insertBulletAfterCaret(list: HTMLElement): boolean { + const sel = window.getSelection(); + if (!sel || sel.rangeCount === 0) return false; + const range = sel.getRangeAt(0); + if (!range.collapsed) range.deleteContents(); + + let row: HTMLElement | null = null; + let node: Node | null = range.endContainer; + while (node && node !== list) { + if (node.parentNode === list) { + row = node as HTMLElement; + break; + } + node = node.parentNode; + } + if (!row) return false; + + let tail = ""; + const caretNode = range.endContainer; + if (caretNode.nodeType === Node.TEXT_NODE) { + const full = caretNode.textContent ?? ""; + tail = full.slice(range.endOffset); + caretNode.textContent = full.slice(0, range.endOffset); + } + + const newRow = row.cloneNode(true) as HTMLElement; + for (const el of [newRow, ...Array.from(newRow.querySelectorAll("*"))]) { + el.removeAttribute("data-builder-id"); + el.removeAttribute("data-fusion-element-id"); + } + row.after(newRow); + primeNewRow(newRow, tail); + return true; +} From befa578dcb6472a3962a92c5fc5def67f7903e5d Mon Sep 17 00:00:00 2001 From: "Builder.io" Date: Thu, 23 Jul 2026 00:24:09 +0000 Subject: [PATCH 08/28] Fix focus loss when pressing Enter at end of bullet point --- .../app/components/editor/SlideEditor.tsx | 36 +++++++++++++++++-- 1 file changed, 33 insertions(+), 3 deletions(-) diff --git a/templates/slides/app/components/editor/SlideEditor.tsx b/templates/slides/app/components/editor/SlideEditor.tsx index 21609b1a27..cd2b5f65d1 100644 --- a/templates/slides/app/components/editor/SlideEditor.tsx +++ b/templates/slides/app/components/editor/SlideEditor.tsx @@ -1239,7 +1239,18 @@ export default function SlideEditor({ (isTextLeaf(editingEl) && RICH_BLOCK_TAGS.has(editingEl.tagName)) || editingBulletList; const onKey = (e: KeyboardEvent) => { - if (e.target instanceof Node && !editingEl.contains(e.target)) return; + // Ignore keys from outside the slide (e.g. the style dock's font-size + // input). Guard against the LIVE slide content, not `editingEl`, which a + // re-render may have detached — a stale ref would wrongly bail here and + // let native Enter run. + const slideContent = getSlideContent(); + if ( + e.target instanceof Node && + slideContent && + !slideContent.contains(e.target) + ) { + return; + } if (e.key === "Escape") { e.preventDefault(); e.stopPropagation(); @@ -1258,7 +1269,20 @@ export default function SlideEditor({ // so the slide layout can never be broken by a stray new node. if (e.shiftKey) return; - if (editingBulletList && insertBulletAfterCaret(editingEl)) { + // Re-derive the list from the LIVE caret so a re-render that swapped + // the edited node can't drop us into native Enter. + const sel = window.getSelection(); + const anchor = sel?.anchorNode ?? null; + const anchorEl = anchor + ? anchor.nodeType === Node.TEXT_NODE + ? anchor.parentElement + : (anchor as HTMLElement) + : null; + const liveList = + anchorEl && slideContent && slideContent.contains(anchorEl) + ? findEnclosingList(anchorEl, slideContent) + : null; + if (liveList && insertBulletAfterCaret(liveList)) { e.preventDefault(); captureInlineEditDraft(slide.id); return; @@ -1272,7 +1296,13 @@ export default function SlideEditor({ }; window.addEventListener("keydown", onKey, true); return () => window.removeEventListener("keydown", onKey, true); - }, [exitInlineEdit, editingEl, captureInlineEditDraft, slide.id]); + }, [ + exitInlineEdit, + editingEl, + captureInlineEditDraft, + slide.id, + getSlideContent, + ]); // Click-outside: exit inline edit mode useEffect(() => { From 68fd3f5433d6e5e7d5424e9afe337b103170efff Mon Sep 17 00:00:00 2001 From: "Builder.io" Date: Thu, 23 Jul 2026 00:40:36 +0000 Subject: [PATCH 09/28] Clean up unused bullet list detection logic in slide editor --- .../slides/app/components/editor/SlideEditor.tsx | 6 +----- .../slides/app/components/editor/bullet-editing.ts | 12 +++--------- 2 files changed, 4 insertions(+), 14 deletions(-) diff --git a/templates/slides/app/components/editor/SlideEditor.tsx b/templates/slides/app/components/editor/SlideEditor.tsx index cd2b5f65d1..c51ad606f6 100644 --- a/templates/slides/app/components/editor/SlideEditor.tsx +++ b/templates/slides/app/components/editor/SlideEditor.tsx @@ -1231,13 +1231,9 @@ export default function SlideEditor({ // mid-edit and incorrectly commit the user out of the block. The user's // intent (rich-block edit vs single-line commit) doesn't change while // they're editing the same node, so latch it. - const editingBulletList = - isBulletList(editingEl) || - ((editingEl.tagName === "UL" || editingEl.tagName === "OL") && - isSmartGroup(editingEl)); const isMultiLineLeaf = (isTextLeaf(editingEl) && RICH_BLOCK_TAGS.has(editingEl.tagName)) || - editingBulletList; + isBulletList(editingEl); const onKey = (e: KeyboardEvent) => { // Ignore keys from outside the slide (e.g. the style dock's font-size // input). Guard against the LIVE slide content, not `editingEl`, which a diff --git a/templates/slides/app/components/editor/bullet-editing.ts b/templates/slides/app/components/editor/bullet-editing.ts index 33d4c65435..8fb35ebca3 100644 --- a/templates/slides/app/components/editor/bullet-editing.ts +++ b/templates/slides/app/components/editor/bullet-editing.ts @@ -63,9 +63,9 @@ export function isBulletList(el: HTMLElement): boolean { } /** - * Walk up from a text leaf to the nearest enclosing list container — either a - * native
                                  /
                                    or a styled bullet-row container — so Enter can add a new - * item to the whole list instead of being trapped inside one item. + * Walk up from a text leaf to the nearest enclosing styled bullet-row + * container, so Enter can add a new item to the whole list instead of being + * trapped inside one item. */ export function findEnclosingList( el: HTMLElement, @@ -75,12 +75,6 @@ export function findEnclosingList( while (node && root.contains(node)) { const parent = node.parentElement; if (!parent) break; - if ( - node.tagName === "LI" && - (parent.tagName === "UL" || parent.tagName === "OL") - ) { - return parent; - } if (isBulletRow(node) && isBulletList(parent)) return parent; // Even from a bullet row whose siblings aren't all bullets, treat the // parent as a list once it holds two or more bullet rows. From c7d20ac25538a56d0fefce14ed834fd4e7cfac3e Mon Sep 17 00:00:00 2001 From: "Builder.io" Date: Thu, 23 Jul 2026 16:27:46 +0000 Subject: [PATCH 10/28] Fix TypeScript type annotation in bullet editing --- .../slides/app/components/editor/bullet-editing.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/templates/slides/app/components/editor/bullet-editing.ts b/templates/slides/app/components/editor/bullet-editing.ts index 8fb35ebca3..fc582c338e 100644 --- a/templates/slides/app/components/editor/bullet-editing.ts +++ b/templates/slides/app/components/editor/bullet-editing.ts @@ -73,13 +73,13 @@ export function findEnclosingList( ): HTMLElement | null { let node: HTMLElement | null = el; while (node && root.contains(node)) { - const parent = node.parentElement; - if (!parent) break; - if (isBulletRow(node) && isBulletList(parent)) return parent; + const parentEl: HTMLElement | null = node.parentElement; + if (!parentEl) break; + if (isBulletRow(node) && isBulletList(parentEl)) return parentEl; // Even from a bullet row whose siblings aren't all bullets, treat the // parent as a list once it holds two or more bullet rows. - if (isBulletRow(node) && bulletRowCount(parent) >= 2) return parent; - node = parent; + if (isBulletRow(node) && bulletRowCount(parentEl) >= 2) return parentEl; + node = parentEl; } return null; } From 4c76138a2f55c39dd887a8707b106f997cba470d Mon Sep 17 00:00:00 2001 From: "Builder.io" Date: Thu, 23 Jul 2026 19:06:39 +0000 Subject: [PATCH 11/28] Fix bullet editing to preserve formatting and handle edge cases --- .../components/editor/bullet-editing.test.tsx | 60 +++++++++++++ .../app/components/editor/bullet-editing.ts | 87 +++++++++++++------ 2 files changed, 120 insertions(+), 27 deletions(-) diff --git a/templates/slides/app/components/editor/bullet-editing.test.tsx b/templates/slides/app/components/editor/bullet-editing.test.tsx index 66ebec3752..6bb54dcf0e 100644 --- a/templates/slides/app/components/editor/bullet-editing.test.tsx +++ b/templates/slides/app/components/editor/bullet-editing.test.tsx @@ -83,6 +83,66 @@ describe("styled bullet editing", () => { expect(newRow.children[1].textContent).toBe(" point"); }); + it("preserves inline formatting when the tail moves to the new bullet", () => { + document.body.innerHTML = + '
                                    ' + + '
                                    \u25CFHello bold tail
                                    ' + + '
                                    \u25CFSecond
                                    ' + + "
                                    "; + const list = document.querySelector(".bullets") as HTMLElement; + const textSpan = list.children[0].children[1] as HTMLElement; + const leadingText = textSpan.firstChild as Text; + placeCaret(leadingText, leadingText.length); + + expect(insertBulletAfterCaret(list)).toBe(true); + expect(list.children.length).toBe(3); + + expect(textSpan.textContent).toBe("Hello "); + const newRow = list.children[1] as HTMLElement; + const newText = newRow.children[1] as HTMLElement; + expect(newText.querySelector("strong")).not.toBeNull(); + expect(newText.querySelector("strong")?.textContent).toBe("bold tail"); + }); + + it("preserves formatting when splitting inside a formatted run", () => { + document.body.innerHTML = + '
                                    ' + + '
                                    \u25CFbold tail
                                    ' + + "
                                    "; + const list = document.querySelector(".bullets") as HTMLElement; + const strong = list.children[0].children[1].firstChild as HTMLElement; + const strongText = strong.firstChild as Text; + placeCaret(strongText, "bold".length); + + expect(insertBulletAfterCaret(list)).toBe(true); + expect(list.children.length).toBe(2); + + const headStrong = list.children[0].children[1].querySelector("strong"); + expect(headStrong?.textContent).toBe("bold"); + const tailStrong = list.children[1].children[1].querySelector("strong"); + expect(tailStrong?.textContent).toBe(" tail"); + }); + + it("does not blank the marker when the caret is inside the marker glyph", () => { + const { list } = setup(); + const firstRow = list.children[0] as HTMLElement; + const markerText = firstRow.children[0].firstChild as Text; + placeCaret(markerText, 0); + + expect(insertBulletAfterCaret(list)).toBe(true); + expect(list.children.length).toBe(4); + + expect(firstRow.children[0].textContent).toBe("\u25CF"); + expect(firstRow.children[1].textContent).toBe("First point"); + expect(isBulletRow(firstRow)).toBe(true); + + const newRow = list.children[1] as HTMLElement; + expect(isBulletRow(newRow)).toBe(true); + expect((newRow.children[1].textContent ?? "").replace(/\u200B/g, "")).toBe( + "", + ); + }); + it("recognizes a list when a row's text is a bare text node", () => { document.body.innerHTML = '
                                    ' + diff --git a/templates/slides/app/components/editor/bullet-editing.ts b/templates/slides/app/components/editor/bullet-editing.ts index fc582c338e..5d7d747a6e 100644 --- a/templates/slides/app/components/editor/bullet-editing.ts +++ b/templates/slides/app/components/editor/bullet-editing.ts @@ -84,48 +84,66 @@ export function findEnclosingList( return null; } +/** The non-marker text container of a row: a dedicated text if present, + * otherwise the row itself (rows whose text is a bare node). */ +function rowTextContainer( + row: HTMLElement, + marker: HTMLElement | null, +): HTMLElement { + const textSpan = Array.from(row.children).find( + (c) => c.tagName === "SPAN" && c !== marker && !isBulletMarker(c), + ) as HTMLElement | undefined; + return textSpan ?? row; +} + /** - * Seed a freshly-inserted row with the given tail text and place the caret at - * the start of its editable text. The text is written into a real text node - * inside the row's non-marker text span so typed characters inherit the row's - * font size (an empty inline span would drop the caret to the container). + * Seed a freshly-inserted row with the caret's trailing content and place the + * caret at the start of its editable text. `tail` is a DOM fragment (not a + * string) so inline formatting such as / carried over from the + * split point is preserved. When there is no tail, a zero-width space text node + * keeps the caret inside the font-carrying text span rather than dropping it to + * the container. */ -function primeNewRow(row: HTMLElement, tail: string): void { +function primeNewRow(row: HTMLElement, tail: DocumentFragment | null): void { const marker = row.firstElementChild && isBulletMarker(row.firstElementChild) ? (row.firstElementChild as HTMLElement) : null; - const textSpan = Array.from(row.children).find( - (c) => c.tagName === "SPAN" && c !== marker && !isBulletMarker(c), - ) as HTMLElement | undefined; - - const initial = tail.length > 0 ? tail : ZERO_WIDTH_SPACE; - const textNode = document.createTextNode(initial); + const container = rowTextContainer(row, marker); - if (textSpan) { - // Reset only the text span; the marker span is a sibling and is preserved. - textSpan.replaceChildren(textNode); + // Clear existing text content, preserving the marker glyph. + if (container !== row) { + container.replaceChildren(); } else { - // No dedicated text span (text is a bare node): keep the marker, drop - // everything after it, then append the fresh text so it inherits the row. while (marker?.nextSibling) marker.nextSibling.remove(); if (!marker) row.replaceChildren(); - row.appendChild(textNode); } + const firstTailNode = tail?.firstChild ?? null; + if (tail && firstTailNode) container.appendChild(tail); + const sel = window.getSelection(); if (!sel) return; const range = document.createRange(); - range.setStart(textNode, tail.length > 0 ? 0 : ZERO_WIDTH_SPACE.length); + if (firstTailNode) { + // Caret at the very start of the moved tail (before the marker is not + // possible: setStartBefore anchors relative to the tail's first node). + range.setStartBefore(firstTailNode); + } else { + const zws = document.createTextNode(ZERO_WIDTH_SPACE); + container.appendChild(zws); + range.setStart(zws, ZERO_WIDTH_SPACE.length); + } range.collapse(true); sel.removeAllRanges(); sel.addRange(range); } /** - * Insert a new list item after the caret's current row. Text after the caret - * moves into the new row; the marker glyph is preserved. Returns false when - * the caret isn't inside a direct row of the list so the caller can fall back. + * Insert a new list item after the caret's current row. Content after the caret + * moves into the new row (with inline formatting preserved); the marker glyph is + * preserved on both rows. Returns false when the caret isn't inside a direct row + * of the list so the caller can fall back. */ export function insertBulletAfterCaret(list: HTMLElement): boolean { const sel = window.getSelection(); @@ -144,12 +162,27 @@ export function insertBulletAfterCaret(list: HTMLElement): boolean { } if (!row) return false; - let tail = ""; - const caretNode = range.endContainer; - if (caretNode.nodeType === Node.TEXT_NODE) { - const full = caretNode.textContent ?? ""; - tail = full.slice(range.endOffset); - caretNode.textContent = full.slice(0, range.endOffset); + const marker = + row.firstElementChild && isBulletMarker(row.firstElementChild) + ? (row.firstElementChild as HTMLElement) + : null; + + // Never split inside the marker glyph itself: a caret at offset 0 of the "●" + // text node (e.g. clicking the marker's leading edge) would otherwise blank + // the marker and un-bullet the row. In that case add an empty bullet instead. + const caretInMarker = !!marker && marker.contains(range.endContainer); + + const container = rowTextContainer(row, marker); + let tail: DocumentFragment | null = null; + if (!caretInMarker && container.contains(range.endContainer)) { + const tailRange = document.createRange(); + tailRange.setStart(range.endContainer, range.endOffset); + const lastChild = container.lastChild; + if (lastChild) tailRange.setEndAfter(lastChild); + else tailRange.setEnd(container, container.childNodes.length); + // extractContents() moves the trailing DOM subtree (preserving / + // ) out of the original row so it can be reparented into the new one. + tail = tailRange.extractContents(); } const newRow = row.cloneNode(true) as HTMLElement; From 7de7840aea673a8674f19f3053912c01f88ffc1c Mon Sep 17 00:00:00 2001 From: "Builder.io" Date: Thu, 23 Jul 2026 19:57:22 +0000 Subject: [PATCH 12/28] Fix bullet marker deletion when selection spans glyph --- .../app/components/editor/bullet-editing.ts | 25 ++++++++++++++++++- 1 file changed, 24 insertions(+), 1 deletion(-) diff --git a/templates/slides/app/components/editor/bullet-editing.ts b/templates/slides/app/components/editor/bullet-editing.ts index 5d7d747a6e..0070f2ef84 100644 --- a/templates/slides/app/components/editor/bullet-editing.ts +++ b/templates/slides/app/components/editor/bullet-editing.ts @@ -32,6 +32,20 @@ export function isBulletMarker(el: Element): boolean { return text.length > 0 && [...text].every((c) => BULLET_GLYPHS.has(c)); } +/** The bullet-marker element enclosing a node (or the node itself), bounded by + * `root`, or null when the node isn't inside a marker glyph. */ +function enclosingMarker(node: Node, root: HTMLElement): HTMLElement | null { + let el: HTMLElement | null = + node.nodeType === Node.ELEMENT_NODE + ? (node as HTMLElement) + : node.parentElement; + while (el && el !== root && root.contains(el)) { + if (isBulletMarker(el)) return el; + el = el.parentElement; + } + return null; +} + /** * A "bullet row" is a styled list item whose first element child is a marker * glyph. The text after it may be a or a bare text node (contentEditable @@ -149,7 +163,16 @@ export function insertBulletAfterCaret(list: HTMLElement): boolean { const sel = window.getSelection(); if (!sel || sel.rangeCount === 0) return false; const range = sel.getRangeAt(0); - if (!range.collapsed) range.deleteContents(); + if (!range.collapsed) { + // A selection that spans a row's marker glyph would delete it here, blanking + // the bullet on the surviving row (and its clone). Clamp both boundaries out + // of any enclosing marker so deletion never touches the glyphs. + const startMarker = enclosingMarker(range.startContainer, list); + if (startMarker) range.setStartAfter(startMarker); + const endMarker = enclosingMarker(range.endContainer, list); + if (endMarker) range.setEndBefore(endMarker); + if (!range.collapsed) range.deleteContents(); + } let row: HTMLElement | null = null; let node: Node | null = range.endContainer; From 782ddaf40050129d5bbfb3bdf59f35db0c6e95aa Mon Sep 17 00:00:00 2001 From: "Builder.io" Date: Thu, 23 Jul 2026 20:27:20 +0000 Subject: [PATCH 13/28] Fix bullet editing to recognize shape-based markers in generated lists --- .../app/components/editor/bullet-editing.ts | 49 ++++++++++++++++++- ...erated-checkbox-shape-marker-lists-now-.md | 6 +++ 2 files changed, 54 insertions(+), 1 deletion(-) create mode 100644 templates/slides/changelog/2026-07-23-pressing-enter-in-generated-checkbox-shape-marker-lists-now-.md diff --git a/templates/slides/app/components/editor/bullet-editing.ts b/templates/slides/app/components/editor/bullet-editing.ts index 0070f2ef84..24c44d5ca7 100644 --- a/templates/slides/app/components/editor/bullet-editing.ts +++ b/templates/slides/app/components/editor/bullet-editing.ts @@ -26,12 +26,59 @@ const BULLET_GLYPHS = new Set([ "*", ]); -/** True if an element is just a bullet marker glyph (e.g. a leading ● span). */ +/** True if an element is a bullet marker — either a text glyph (a leading ● + * span) or an empty CSS shape (a small square/dot/box span used as a marker). */ export function isBulletMarker(el: Element): boolean { + return isGlyphMarker(el) || isShapeMarker(el); +} + +/** A leading span whose text is only bullet glyph characters (e.g. "●"). */ +function isGlyphMarker(el: Element): boolean { const text = (el.textContent ?? "").trim(); return text.length > 0 && [...text].every((c) => BULLET_GLYPHS.has(c)); } +/** An empty, small, roughly-square span drawn as a marker via border/background + * (e.g. ``), which is + * how generated decks often render checkbox/dot bullets with no text glyph. */ +function isShapeMarker(el: Element): boolean { + if ((el.textContent ?? "").trim().length > 0) return false; + if (el.childElementCount > 0) return false; + const w = parseCssPx(styleValue(el, "width")); + const h = parseCssPx(styleValue(el, "height")); + if (!(w > 0 && h > 0) || w > 48 || h > 48) return false; + const ratio = w / h; + if (ratio < 0.5 || ratio > 2) return false; + const hasBorder = + parseCssPx(styleValue(el, "border-top-width")) > 0 || + parseCssPx(styleValue(el, "border-left-width")) > 0 || + parseCssPx(styleValue(el, "border-width")) > 0; + const bg = styleValue(el, "background-color"); + const hasBg = !!bg && bg !== "transparent" && bg !== "rgba(0, 0, 0, 0)"; + const hasRadius = parseCssPx(styleValue(el, "border-radius")) > 0; + return hasBorder || hasBg || hasRadius; +} + +/** Read a style property, preferring inline styles and falling back to computed + * styles when available (jsdom-safe). */ +function styleValue(el: Element, prop: string): string { + const inline = (el as HTMLElement).style?.getPropertyValue(prop); + if (inline) return inline; + if (typeof window !== "undefined" && window.getComputedStyle) { + try { + return window.getComputedStyle(el).getPropertyValue(prop); + } catch { + return ""; + } + } + return ""; +} + +function parseCssPx(value: string): number { + const parsed = Number.parseFloat(value); + return Number.isFinite(parsed) ? parsed : 0; +} + /** The bullet-marker element enclosing a node (or the node itself), bounded by * `root`, or null when the node isn't inside a marker glyph. */ function enclosingMarker(node: Node, root: HTMLElement): HTMLElement | null { diff --git a/templates/slides/changelog/2026-07-23-pressing-enter-in-generated-checkbox-shape-marker-lists-now-.md b/templates/slides/changelog/2026-07-23-pressing-enter-in-generated-checkbox-shape-marker-lists-now-.md new file mode 100644 index 0000000000..5bb60cf6c2 --- /dev/null +++ b/templates/slides/changelog/2026-07-23-pressing-enter-in-generated-checkbox-shape-marker-lists-now-.md @@ -0,0 +1,6 @@ +--- +type: fixed +date: 2026-07-23 +--- + +Pressing Enter in generated checkbox/shape-marker lists now creates a new list item From b7f0f90011a11008e5ac1b3786daca3f05c7baaa Mon Sep 17 00:00:00 2001 From: "Builder.io" Date: Thu, 23 Jul 2026 22:48:48 +0000 Subject: [PATCH 14/28] Fix font size shrinking in new bullet rows created with Enter --- .../app/components/editor/SlideEditor.tsx | 41 ++++++++++++++++--- ...ed-with-enter-now-keep-the-list-item-s-.md | 6 +++ 2 files changed, 42 insertions(+), 5 deletions(-) create mode 100644 templates/slides/changelog/2026-07-23-new-bullet-rows-created-with-enter-now-keep-the-list-item-s-.md diff --git a/templates/slides/app/components/editor/SlideEditor.tsx b/templates/slides/app/components/editor/SlideEditor.tsx index c51ad606f6..94572bf493 100644 --- a/templates/slides/app/components/editor/SlideEditor.tsx +++ b/templates/slides/app/components/editor/SlideEditor.tsx @@ -39,6 +39,7 @@ import { findEnclosingList, insertBulletAfterCaret, isBulletList, + ZERO_WIDTH_SPACE, } from "@/components/editor/bullet-editing"; import { Button } from "@/components/ui/button"; import { @@ -212,13 +213,43 @@ function stripBuilderIds(html: string): string { } parent.removeChild(wrapper); } - cleaned = - doc.querySelector("[data-strip-root]")?.innerHTML ?? doc.body.innerHTML; + const stripRoot = doc.querySelector("[data-strip-root]"); + if (stripRoot) stripPlaceholderZws(stripRoot); + cleaned = stripRoot?.innerHTML ?? doc.body.innerHTML; } - return cleaned - .replace(/\s*data-builder-id="[^"]*"/g, "") - .replace(/\u200B/g, ""); + return cleaned.replace(/\s*data-builder-id="[^"]*"/g, ""); +} + +/** + * Remove zero-width-space characters used only as caret placeholders, while + * preserving a lone ZWS that is the sole content of an element — that ZWS keeps + * an empty bullet's text span from collapsing, so it retains its font. Stripping + * every ZWS (as a blanket regex did) drops that anchor and makes the next typed + * character fall back to the container's base font. + */ +function stripPlaceholderZws(root: Element): void { + const walker = root.ownerDocument.createTreeWalker( + root, + NodeFilter.SHOW_TEXT, + ); + const textNodes: Text[] = []; + for ( + let node = walker.nextNode(); + node; + node = walker.nextNode() + ) { + textNodes.push(node as Text); + } + for (const textNode of textNodes) { + if (!textNode.data.includes(ZERO_WIDTH_SPACE)) continue; + const withoutZws = textNode.data.replaceAll(ZERO_WIDTH_SPACE, ""); + if (withoutZws.length > 0) { + textNode.data = withoutZws; + } else if (textNode.parentNode?.childNodes.length !== 1) { + textNode.remove(); + } + } } function cssPx(value: string): number { diff --git a/templates/slides/changelog/2026-07-23-new-bullet-rows-created-with-enter-now-keep-the-list-item-s-.md b/templates/slides/changelog/2026-07-23-new-bullet-rows-created-with-enter-now-keep-the-list-item-s-.md new file mode 100644 index 0000000000..3f54b05b34 --- /dev/null +++ b/templates/slides/changelog/2026-07-23-new-bullet-rows-created-with-enter-now-keep-the-list-item-s-.md @@ -0,0 +1,6 @@ +--- +type: fixed +date: 2026-07-23 +--- + +New bullet rows created with Enter now keep the list item's font size instead of shrinking to the base font From 8b8f7150e1bfe55711031a92db143b261df9d781 Mon Sep 17 00:00:00 2001 From: "Builder.io" Date: Thu, 23 Jul 2026 22:49:41 +0000 Subject: [PATCH 15/28] Fix formatting in SlideEditor component --- .../slides/app/components/editor/SlideEditor.tsx | 11 ++--------- 1 file changed, 2 insertions(+), 9 deletions(-) diff --git a/templates/slides/app/components/editor/SlideEditor.tsx b/templates/slides/app/components/editor/SlideEditor.tsx index 94572bf493..2b7f5ea84f 100644 --- a/templates/slides/app/components/editor/SlideEditor.tsx +++ b/templates/slides/app/components/editor/SlideEditor.tsx @@ -229,16 +229,9 @@ function stripBuilderIds(html: string): string { * character fall back to the container's base font. */ function stripPlaceholderZws(root: Element): void { - const walker = root.ownerDocument.createTreeWalker( - root, - NodeFilter.SHOW_TEXT, - ); + const walker = root.ownerDocument.createTreeWalker(root, NodeFilter.SHOW_TEXT); const textNodes: Text[] = []; - for ( - let node = walker.nextNode(); - node; - node = walker.nextNode() - ) { + for (let node = walker.nextNode(); node; node = walker.nextNode()) { textNodes.push(node as Text); } for (const textNode of textNodes) { From e17dac3b54711ae2b6a4df00bead4e8141969d89 Mon Sep 17 00:00:00 2001 From: "Builder.io" Date: Thu, 23 Jul 2026 22:51:36 +0000 Subject: [PATCH 16/28] Fix formatting in SlideEditor component --- templates/slides/app/components/editor/SlideEditor.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/templates/slides/app/components/editor/SlideEditor.tsx b/templates/slides/app/components/editor/SlideEditor.tsx index 2b7f5ea84f..e00ac0dc86 100644 --- a/templates/slides/app/components/editor/SlideEditor.tsx +++ b/templates/slides/app/components/editor/SlideEditor.tsx @@ -229,7 +229,10 @@ function stripBuilderIds(html: string): string { * character fall back to the container's base font. */ function stripPlaceholderZws(root: Element): void { - const walker = root.ownerDocument.createTreeWalker(root, NodeFilter.SHOW_TEXT); + const walker = root.ownerDocument.createTreeWalker( + root, + NodeFilter.SHOW_TEXT, + ); const textNodes: Text[] = []; for (let node = walker.nextNode(); node; node = walker.nextNode()) { textNodes.push(node as Text); From 6c45edb1e63be348bf466acc8069c446ef4766bc Mon Sep 17 00:00:00 2001 From: "Builder.io" Date: Thu, 23 Jul 2026 23:16:54 +0000 Subject: [PATCH 17/28] Fix bullet text span seeding with zero-width-space character --- .../components/editor/bullet-editing.test.tsx | 20 +++++++++++++++++++ .../app/components/editor/bullet-editing.ts | 8 ++++++++ 2 files changed, 28 insertions(+) diff --git a/templates/slides/app/components/editor/bullet-editing.test.tsx b/templates/slides/app/components/editor/bullet-editing.test.tsx index 6bb54dcf0e..61264a880f 100644 --- a/templates/slides/app/components/editor/bullet-editing.test.tsx +++ b/templates/slides/app/components/editor/bullet-editing.test.tsx @@ -70,6 +70,26 @@ describe("styled bullet editing", () => { expect((newRow as HTMLElement).style.fontSize).toBe("22px"); }); + it("seeds the new bullet's text span with a real zero-width-space character, not an empty tail node", () => { + // Regression test: Range.extractContents() on a collapsed range (caret at + // the very end of the text, the common case) still clones the boundary + // text node with empty data instead of returning a childless fragment. + // If that empty node is mistaken for a real "tail" to move over, the new + // row's text span ends up with a contentless text node instead of the + // zero-width-space placeholder, and the caret has nothing to anchor its + // font to. + const { list } = setup(); + const thirdText = list.children[2].children[1] as HTMLElement; + const textNode = thirdText.firstChild as Text; + placeCaret(textNode, textNode.length); + + expect(insertBulletAfterCaret(list)).toBe(true); + const newTextSpan = list.children[3].children[1] as HTMLElement; + expect(newTextSpan.childNodes.length).toBe(1); + expect(newTextSpan.firstChild?.nodeType).toBe(Node.TEXT_NODE); + expect((newTextSpan.firstChild as Text).data).toBe("\u200B"); + }); + it("splits text after the caret into the new bullet", () => { const { list } = setup(); const secondText = list.children[1].children[1] as HTMLElement; diff --git a/templates/slides/app/components/editor/bullet-editing.ts b/templates/slides/app/components/editor/bullet-editing.ts index 24c44d5ca7..5b55cb78ec 100644 --- a/templates/slides/app/components/editor/bullet-editing.ts +++ b/templates/slides/app/components/editor/bullet-editing.ts @@ -253,6 +253,14 @@ export function insertBulletAfterCaret(list: HTMLElement): boolean { // extractContents() moves the trailing DOM subtree (preserving / // ) out of the original row so it can be reparented into the new one. tail = tailRange.extractContents(); + // A caret at the very end of the text (the common case) makes tailRange + // collapsed, but extractContents() on a collapsed range still clones the + // boundary text node with empty data instead of returning an empty + // fragment. Treat that as "no tail" so primeNewRow falls through to the + // zero-width-space placeholder — otherwise it moves in an empty text node + // with no character to anchor the caret's font to, and typing falls back + // to the marker span's formatting instead of the text span's. + if (tail.textContent === "") tail = null; } const newRow = row.cloneNode(true) as HTMLElement; From 5d023628d414ea79a5ab93722b90e1be9f76e8f7 Mon Sep 17 00:00:00 2001 From: "Builder.io" Date: Fri, 24 Jul 2026 00:01:04 +0000 Subject: [PATCH 18/28] Add markdown-style bullet list autoformat in slide editor --- .../app/components/editor/SlideEditor.tsx | 10 ++- .../components/editor/bullet-editing.test.tsx | 80 +++++++++++++++++++ .../app/components/editor/bullet-editing.ts | 68 ++++++++++++++++ ...le-dash-space-at-the-start-of-a-text-bl.md | 6 ++ 4 files changed, 163 insertions(+), 1 deletion(-) create mode 100644 templates/slides/changelog/2026-07-24-typing-a-markdown-style-dash-space-at-the-start-of-a-text-bl.md diff --git a/templates/slides/app/components/editor/SlideEditor.tsx b/templates/slides/app/components/editor/SlideEditor.tsx index e00ac0dc86..5244e09e1f 100644 --- a/templates/slides/app/components/editor/SlideEditor.tsx +++ b/templates/slides/app/components/editor/SlideEditor.tsx @@ -36,6 +36,7 @@ import { ExcalidrawSlide } from "@/components/deck/ExcalidrawSlide"; import SlideRenderer from "@/components/deck/SlideRenderer"; import type { SlideOverflowInfo } from "@/components/deck/SlideRenderer"; import { + convertMarkdownPrefixToBullet, findEnclosingList, insertBulletAfterCaret, isBulletList, @@ -1244,7 +1245,14 @@ export default function SlideEditor({ useEffect(() => { if (!editingEl) return; const editingSlideId = slide.id; - const handleInput = () => captureInlineEditDraft(editingSlideId); + const handleInput = () => { + // Markdown-style "- "/"* " at the start of a plain text block converts + // it into a styled bullet row, so it's recognized as a list and Enter + // can extend it — contentEditable has no native concept of these + // styled (non-
                                      ) bullet lists. + convertMarkdownPrefixToBullet(editingEl); + captureInlineEditDraft(editingSlideId); + }; editingEl.addEventListener("input", handleInput); return () => editingEl.removeEventListener("input", handleInput); }, [captureInlineEditDraft, editingEl, slide.id]); diff --git a/templates/slides/app/components/editor/bullet-editing.test.tsx b/templates/slides/app/components/editor/bullet-editing.test.tsx index 61264a880f..c8e3006954 100644 --- a/templates/slides/app/components/editor/bullet-editing.test.tsx +++ b/templates/slides/app/components/editor/bullet-editing.test.tsx @@ -2,6 +2,7 @@ import { beforeEach, describe, expect, it } from "vitest"; import { + convertMarkdownPrefixToBullet, findEnclosingList, insertBulletAfterCaret, isBulletList, @@ -195,3 +196,82 @@ describe("styled bullet editing", () => { expect(isBulletList(list)).toBe(true); }); }); + +describe("markdown prefix autoformat", () => { + beforeEach(() => { + document.body.innerHTML = ""; + }); + + it("converts a leading '- ' into a bullet row nested inside the block", () => { + document.body.innerHTML = + '
                                      -
                                      '; + const root = document.querySelector(".slide-content") as HTMLElement; + const el = root.firstElementChild as HTMLElement; + const textNode = el.firstChild as Text; + placeCaret(textNode, textNode.length); + + expect(convertMarkdownPrefixToBullet(el)).toBe(true); + expect(el.style.display).toBe("flex"); + expect(el.style.flexDirection).toBe("column"); + expect(el.children.length).toBe(1); + + const row = el.children[0] as HTMLElement; + expect(isBulletRow(row)).toBe(true); + expect(isBulletList(el)).toBe(true); + expect(row.children[0].textContent).toBe("\u25CF"); + expect((row.children[1].textContent ?? "").replace(/\u200B/g, "")).toBe(""); + + const sel = window.getSelection(); + const anchor = sel?.anchorNode; + const landedInTextSpan = + anchor === row.children[1] || anchor?.parentElement === row.children[1]; + const landedInMarker = + anchor === row.children[0] || anchor?.parentElement === row.children[0]; + expect(landedInTextSpan).toBe(true); + expect(landedInMarker).toBe(false); + }); + + it("lets Enter extend a list created from a markdown prefix", () => { + document.body.innerHTML = + '
                                      - hi
                                      '; + const root = document.querySelector(".slide-content") as HTMLElement; + const el = root.firstElementChild as HTMLElement; + const textNode = el.firstChild as Text; + placeCaret(textNode, "- ".length); + + expect(convertMarkdownPrefixToBullet(el)).toBe(true); + const row = el.children[0] as HTMLElement; + expect(row.children[1].textContent).toBe("hi"); + + const textNodeAfterConvert = row.children[1].firstChild as Text; + placeCaret(textNodeAfterConvert, textNodeAfterConvert.length); + expect(findEnclosingList(row.children[1] as HTMLElement, root)).toBe(el); + expect(insertBulletAfterCaret(el)).toBe(true); + expect(el.children.length).toBe(2); + expect(isBulletRow(el.children[1] as HTMLElement)).toBe(true); + }); + + it("does not convert once the block is already a bullet row", () => { + document.body.innerHTML = + '
                                      \u25CF- text
                                      '; + const root = document.querySelector(".slide-content") as HTMLElement; + const row = root.firstElementChild as HTMLElement; + const textSpan = row.children[1] as HTMLElement; + const textNode = textSpan.firstChild as Text; + placeCaret(textNode, textNode.length); + + expect(convertMarkdownPrefixToBullet(row)).toBe(false); + }); + + it("does not convert when there is no markdown prefix", () => { + document.body.innerHTML = + '
                                      Just text
                                      '; + const root = document.querySelector(".slide-content") as HTMLElement; + const el = root.firstElementChild as HTMLElement; + const textNode = el.firstChild as Text; + placeCaret(textNode, textNode.length); + + expect(convertMarkdownPrefixToBullet(el)).toBe(false); + expect(el.children.length).toBe(0); + }); +}); diff --git a/templates/slides/app/components/editor/bullet-editing.ts b/templates/slides/app/components/editor/bullet-editing.ts index 5b55cb78ec..7ef3749ed4 100644 --- a/templates/slides/app/components/editor/bullet-editing.ts +++ b/templates/slides/app/components/editor/bullet-editing.ts @@ -123,6 +123,74 @@ export function isBulletList(el: HTMLElement): boolean { return rows >= 1 && rows >= kids.length - 1; } +/** Regex for a markdown-style bullet prefix: a dash or asterisk plus a space, + * at the very start of a block's content (e.g. "- " or "* "). */ +const MARKDOWN_BULLET_PREFIX = /^[-*] $/; + +/** + * If `el`'s content starts with a markdown-style "- "/"* " prefix and the + * caret sits right after it, convert `el`'s content into a styled bullet row + * — a small marker span plus a text span holding the rest of `el`'s content — + * nested inside `el`, which becomes the list container. `el` itself must stay + * the contentEditable root (nesting the row rather than turning `el` itself + * into the row) so a later Enter's cloned sibling row lands inside the same + * contentEditable boundary and is actually typeable. Returns false when `el` + * is already a bullet row/list, there's no such prefix, or the selection + * isn't a collapsed caret. + */ +export function convertMarkdownPrefixToBullet(el: HTMLElement): boolean { + if (isBulletRow(el) || isBulletList(el)) return false; + const sel = window.getSelection(); + if (!sel || sel.rangeCount === 0) return false; + const caretRange = sel.getRangeAt(0); + if (!caretRange.collapsed) return false; + + const beforeCaretRange = document.createRange(); + beforeCaretRange.selectNodeContents(el); + beforeCaretRange.setEnd(caretRange.endContainer, caretRange.endOffset); + if (!MARKDOWN_BULLET_PREFIX.test(beforeCaretRange.toString())) return false; + beforeCaretRange.deleteContents(); + + const marker = document.createElement("span"); + marker.style.fontSize = "0.3em"; + marker.style.position = "relative"; + marker.style.top = "-0.15em"; + marker.textContent = "\u25CF"; + + const textSpan = document.createElement("span"); + while (el.firstChild) textSpan.appendChild(el.firstChild); + const restFirstChild = textSpan.firstChild; + if (!restFirstChild) { + textSpan.appendChild(document.createTextNode(ZERO_WIDTH_SPACE)); + } + + const row = document.createElement("div"); + row.style.display = "flex"; + row.style.alignItems = "baseline"; + row.style.gap = "0.7em"; + row.append(marker, textSpan); + el.append(row); + + if (!el.style.display) el.style.display = "flex"; + if (!el.style.flexDirection) el.style.flexDirection = "column"; + if (!el.style.gap) el.style.gap = "0.6em"; + + const range = document.createRange(); + if (restFirstChild) { + range.setStartBefore(restFirstChild); + } else { + // See primeNewRow: anchor the caret inside the placeholder text node + // (not an element-based position) so it keeps the text span's font + // instead of falling back to the marker's. + const zws = textSpan.firstChild as Text; + range.setStart(zws, ZERO_WIDTH_SPACE.length); + } + range.collapse(true); + sel.removeAllRanges(); + sel.addRange(range); + return true; +} + /** * Walk up from a text leaf to the nearest enclosing styled bullet-row * container, so Enter can add a new item to the whole list instead of being diff --git a/templates/slides/changelog/2026-07-24-typing-a-markdown-style-dash-space-at-the-start-of-a-text-bl.md b/templates/slides/changelog/2026-07-24-typing-a-markdown-style-dash-space-at-the-start-of-a-text-bl.md new file mode 100644 index 0000000000..89ffe5960c --- /dev/null +++ b/templates/slides/changelog/2026-07-24-typing-a-markdown-style-dash-space-at-the-start-of-a-text-bl.md @@ -0,0 +1,6 @@ +--- +type: added +date: 2026-07-24 +--- + +Typing a markdown-style dash-space at the start of a text block now converts it into an editable bullet list From 00913534e8cd164e9b5a996dcef98a1764bda6e6 Mon Sep 17 00:00:00 2001 From: "Builder.io" Date: Fri, 24 Jul 2026 01:01:58 +0000 Subject: [PATCH 19/28] Add text box tool to slides editor toolbar --- .../app/components/editor/EditorToolbar.tsx | 33 ++++++++++-- .../app/components/editor/SlideEditor.tsx | 54 ++++++++++++++++++- .../app/components/editor/bullet-editing.ts | 7 +-- templates/slides/app/context/DeckContext.tsx | 4 +- templates/slides/app/i18n/ar-SA.ts | 1 + templates/slides/app/i18n/de-DE.ts | 1 + templates/slides/app/i18n/en-US.ts | 1 + templates/slides/app/i18n/es-ES.ts | 1 + templates/slides/app/i18n/fr-FR.ts | 1 + templates/slides/app/i18n/hi-IN.ts | 1 + templates/slides/app/i18n/ja-JP.ts | 1 + templates/slides/app/i18n/ko-KR.ts | 1 + templates/slides/app/i18n/pt-BR.ts | 1 + templates/slides/app/i18n/zh-CN.ts | 1 + templates/slides/app/i18n/zh-TW.ts | 1 + templates/slides/app/pages/DeckEditor.tsx | 5 ++ 16 files changed, 103 insertions(+), 11 deletions(-) diff --git a/templates/slides/app/components/editor/EditorToolbar.tsx b/templates/slides/app/components/editor/EditorToolbar.tsx index 36e3f6f56f..256471fafd 100644 --- a/templates/slides/app/components/editor/EditorToolbar.tsx +++ b/templates/slides/app/components/editor/EditorToolbar.tsx @@ -29,6 +29,7 @@ import { IconAdjustments, IconPencilPlus, IconPin, + IconLetterT, IconWand, IconUpload, IconSun, @@ -123,6 +124,10 @@ interface EditorToolbarProps { pinMode?: boolean; /** Toggle comment-pin drop mode */ onTogglePinMode?: () => void; + /** Whether the add-text-box tool is active */ + textBoxMode?: boolean; + /** Toggle the add-text-box tool */ + onToggleTextBoxMode?: () => void; /** Duplicate the current deck */ onDuplicateDeck?: () => void; /** Export the deck as PDF */ @@ -247,6 +252,8 @@ export default function EditorToolbar({ onToggleDrawMode, pinMode, onTogglePinMode, + textBoxMode, + onToggleTextBoxMode, onDuplicateDeck, onExportPdf, onExportPptx, @@ -305,10 +312,10 @@ export default function EditorToolbar({ const [themeMounted, setThemeMounted] = useState(false); useEffect(() => setThemeMounted(true), []); const isDark = themeMounted ? resolvedTheme === "dark" : false; - // The four secondary tools share an "active when something is on" indicator - // so the dot on the consolidated button reflects any of them. + // The secondary tools share an "active when something is on" indicator so + // the dot on the consolidated button reflects any of them. const anyToolActive = Boolean( - animationsOpen || tweaksOpen || drawMode || pinMode, + animationsOpen || tweaksOpen || drawMode || pinMode || textBoxMode, ); const closeAll = () => { @@ -731,7 +738,8 @@ graph TD (onToggleAnimations || onToggleTweaks || onToggleDrawMode || - onTogglePinMode) && ( + onTogglePinMode || + onToggleTextBoxMode) && ( <> @@ -834,6 +842,23 @@ graph TD )} + {onToggleTextBoxMode && ( + + )}
                                    diff --git a/templates/slides/app/components/editor/SlideEditor.tsx b/templates/slides/app/components/editor/SlideEditor.tsx index 5244e09e1f..08724bd1b3 100644 --- a/templates/slides/app/components/editor/SlideEditor.tsx +++ b/templates/slides/app/components/editor/SlideEditor.tsx @@ -436,6 +436,11 @@ interface SlideEditorProps { pinMode?: boolean; /** Called when pin mode should exit */ onExitPinMode?: () => void; + /** Whether the "add text box" tool is active — the next click on the slide + * places a new text box there instead of selecting/marquee-selecting */ + textBoxMode?: boolean; + /** Called after a text box is placed (or the tool should otherwise exit) */ + onExitTextBoxMode?: () => void; /** Slide id for pin mode contextId — falls back to slide.id if omitted */ slideId?: string; /** Slide title for pin mode contextLabel */ @@ -779,6 +784,8 @@ export default function SlideEditor({ onExitDrawMode, pinMode, onExitPinMode, + textBoxMode, + onExitTextBoxMode, slideId, slideTitle, deckId, @@ -1627,16 +1634,56 @@ export default function SlideEditor({ [], ); + const placeTextBoxAt = useCallback( + (clientX: number, clientY: number) => { + const fmdSlide = containerRef.current?.querySelector( + ".fmd-slide", + ) as HTMLElement | null; + if (!fmdSlide) return; + const rect = fmdSlide.getBoundingClientRect(); + const x = Math.max(0, Math.round(clientX - rect.left)); + const y = Math.max(0, Math.round(clientY - rect.top)); + + if (getComputedStyle(fmdSlide).position === "static") { + fmdSlide.style.position = "relative"; + } + + const box = document.createElement("div"); + box.style.position = "absolute"; + box.style.left = String(x) + "px"; + box.style.top = String(y) + "px"; + box.style.width = "320px"; + box.style.fontSize = "24px"; + box.style.color = "#fff"; + box.style.fontFamily = "'Poppins', sans-serif"; + box.style.lineHeight = "1.3"; + box.textContent = ZERO_WIDTH_SPACE; + fmdSlide.appendChild(box); + + enterInlineEdit(box); + }, + [enterInlineEdit], + ); + // --- Marquee drag handlers (attached to slide-content via React props) --- const handleSlidePointerDown = useCallback( (e: React.PointerEvent) => { - if (editingEl) return; // don't interfere with inline edit if (e.button !== 0) return; // left click only const slideContent = getSlideContent(); if (!slideContent) return; const target = e.target as HTMLElement; + if (textBoxMode && isHtmlSlide) { + if (!isSlideWhitespaceTarget(target, slideContent)) return; + e.preventDefault(); + if (editingEl) exitInlineEdit(); + placeTextBoxAt(e.clientX, e.clientY); + onExitTextBoxMode?.(); + return; + } + if (editingEl) return; // avoid interfering with an active inline edit + // Only start a marquee from "whitespace" inside the slide. Clicks on // an actual element fall through to handleSlideClick (which handles // shift/cmd-click toggle below). @@ -1665,6 +1712,11 @@ export default function SlideEditor({ multiSelection, applyMultiSelection, clearSelectedElement, + textBoxMode, + isHtmlSlide, + exitInlineEdit, + placeTextBoxAt, + onExitTextBoxMode, ], ); diff --git a/templates/slides/app/components/editor/bullet-editing.ts b/templates/slides/app/components/editor/bullet-editing.ts index 7ef3749ed4..82be612359 100644 --- a/templates/slides/app/components/editor/bullet-editing.ts +++ b/templates/slides/app/components/editor/bullet-editing.ts @@ -160,8 +160,10 @@ export function convertMarkdownPrefixToBullet(el: HTMLElement): boolean { const textSpan = document.createElement("span"); while (el.firstChild) textSpan.appendChild(el.firstChild); const restFirstChild = textSpan.firstChild; + let placeholderZws: Text | null = null; if (!restFirstChild) { - textSpan.appendChild(document.createTextNode(ZERO_WIDTH_SPACE)); + placeholderZws = document.createTextNode(ZERO_WIDTH_SPACE); + textSpan.appendChild(placeholderZws); } const row = document.createElement("div"); @@ -182,8 +184,7 @@ export function convertMarkdownPrefixToBullet(el: HTMLElement): boolean { // See primeNewRow: anchor the caret inside the placeholder text node // (not an element-based position) so it keeps the text span's font // instead of falling back to the marker's. - const zws = textSpan.firstChild as Text; - range.setStart(zws, ZERO_WIDTH_SPACE.length); + range.setStart(placeholderZws as Text, ZERO_WIDTH_SPACE.length); } range.collapse(true); sel.removeAllRanges(); diff --git a/templates/slides/app/context/DeckContext.tsx b/templates/slides/app/context/DeckContext.tsx index 6c066a05bc..7af9f022f5 100644 --- a/templates/slides/app/context/DeckContext.tsx +++ b/templates/slides/app/context/DeckContext.tsx @@ -945,9 +945,7 @@ export const defaultSlideContent: Record = { "full-image": `
                                    Full-bleed image or screenshot
                                    `, - blank: `
                                    -
                                    Double-click to edit
                                    -
                                    `, + blank: `
                                    `, }; export function DeckProvider({ children }: { children: ReactNode }) { diff --git a/templates/slides/app/i18n/ar-SA.ts b/templates/slides/app/i18n/ar-SA.ts index dd3fc7c5f8..4e05a879dc 100644 --- a/templates/slides/app/i18n/ar-SA.ts +++ b/templates/slides/app/i18n/ar-SA.ts @@ -213,6 +213,7 @@ const messages = { elementAnimations: "حركات العناصر", tweaks: "تعديلات", drawOnSlide: "الرسم على الشريحة", + addTextBox: "إضافة مربع نص", pinComments: "تثبيت التعليقات", pinCommentsDescription: "انقر على مواضع في الشريحة لإضافة عدة تعديلات إلى الطابور، ثم أرسلها دفعة واحدة.", diff --git a/templates/slides/app/i18n/de-DE.ts b/templates/slides/app/i18n/de-DE.ts index 5ae206de16..117e0269bd 100644 --- a/templates/slides/app/i18n/de-DE.ts +++ b/templates/slides/app/i18n/de-DE.ts @@ -214,6 +214,7 @@ const messages = { elementAnimations: "Elementanimationen", tweaks: "Feinabstimmung", drawOnSlide: "Auf Folie zeichnen", + addTextBox: "Textfeld hinzufügen", pinComments: "Kommentare anheften", pinCommentsDescription: "Klicke auf Stellen auf der Folie, um mehrere Änderungen vorzubereiten und sie dann gesammelt zu senden.", diff --git a/templates/slides/app/i18n/en-US.ts b/templates/slides/app/i18n/en-US.ts index dafbbd57a3..0f84b08b6d 100644 --- a/templates/slides/app/i18n/en-US.ts +++ b/templates/slides/app/i18n/en-US.ts @@ -210,6 +210,7 @@ const messages = { elementAnimations: "Element animations", tweaks: "Tweaks", drawOnSlide: "Draw on slide", + addTextBox: "Add text box", pinComments: "Pin comments", pinCommentsDescription: "Click spots on the slide to queue several edits, then send them all at once.", diff --git a/templates/slides/app/i18n/es-ES.ts b/templates/slides/app/i18n/es-ES.ts index b155b4acf9..ac309098c4 100644 --- a/templates/slides/app/i18n/es-ES.ts +++ b/templates/slides/app/i18n/es-ES.ts @@ -216,6 +216,7 @@ const messages = { elementAnimations: "Animaciones de elementos", tweaks: "Ajustes finos", drawOnSlide: "Dibujar en la diapositiva", + addTextBox: "Añadir cuadro de texto", pinComments: "Fijar comentarios", pinCommentsDescription: "Haz clic en puntos de la diapositiva para encolar varias ediciones y enviarlas todas a la vez.", diff --git a/templates/slides/app/i18n/fr-FR.ts b/templates/slides/app/i18n/fr-FR.ts index d5452ec0f9..be757a59bf 100644 --- a/templates/slides/app/i18n/fr-FR.ts +++ b/templates/slides/app/i18n/fr-FR.ts @@ -218,6 +218,7 @@ const messages = { elementAnimations: "Animations d’éléments", tweaks: "Réglages", drawOnSlide: "Dessiner sur la diapositive", + addTextBox: "Ajouter une zone de texte", pinComments: "Épingler des commentaires", pinCommentsDescription: "Cliquez sur des zones de la diapositive pour préparer plusieurs modifications, puis envoyez-les ensemble.", diff --git a/templates/slides/app/i18n/hi-IN.ts b/templates/slides/app/i18n/hi-IN.ts index 01eda58056..3ec86cfac2 100644 --- a/templates/slides/app/i18n/hi-IN.ts +++ b/templates/slides/app/i18n/hi-IN.ts @@ -209,6 +209,7 @@ const messages = { elementAnimations: "एलिमेंट एनिमेशन", tweaks: "ट्वीक्स", drawOnSlide: "स्लाइड पर ड्रॉ करें", + addTextBox: "टेक्स्ट बॉक्स जोड़ें", pinComments: "टिप्पणियां पिन करें", pinCommentsDescription: "कई एडिट कतार में डालने के लिए स्लाइड पर जगहों पर क्लिक करें, फिर उन्हें एक साथ भेजें।", diff --git a/templates/slides/app/i18n/ja-JP.ts b/templates/slides/app/i18n/ja-JP.ts index 00d0188256..a5286b992d 100644 --- a/templates/slides/app/i18n/ja-JP.ts +++ b/templates/slides/app/i18n/ja-JP.ts @@ -210,6 +210,7 @@ const messages = { elementAnimations: "要素アニメーション", tweaks: "調整", drawOnSlide: "スライドに描画", + addTextBox: "テキストボックスを追加", pinComments: "コメントをピン留め", pinCommentsDescription: "スライド上の位置をクリックして複数の編集をキューに入れ、まとめて送信します。", diff --git a/templates/slides/app/i18n/ko-KR.ts b/templates/slides/app/i18n/ko-KR.ts index c859856b95..5fd89a034b 100644 --- a/templates/slides/app/i18n/ko-KR.ts +++ b/templates/slides/app/i18n/ko-KR.ts @@ -210,6 +210,7 @@ const messages = { elementAnimations: "요소 애니메이션", tweaks: "조정", drawOnSlide: "슬라이드에 그리기", + addTextBox: "텍스트 상자 추가", pinComments: "댓글 고정", pinCommentsDescription: "슬라이드의 지점을 클릭해 여러 편집을 대기열에 넣고 한 번에 보냅니다.", diff --git a/templates/slides/app/i18n/pt-BR.ts b/templates/slides/app/i18n/pt-BR.ts index 7191d6a4d8..4aa2931343 100644 --- a/templates/slides/app/i18n/pt-BR.ts +++ b/templates/slides/app/i18n/pt-BR.ts @@ -211,6 +211,7 @@ const messages = { elementAnimations: "Animações de elementos", tweaks: "Ajustes", drawOnSlide: "Desenhar no slide", + addTextBox: "Adicionar caixa de texto", pinComments: "Fixar comentários", pinCommentsDescription: "Clique em pontos no slide para enfileirar várias edições e enviá-las de uma vez.", diff --git a/templates/slides/app/i18n/zh-CN.ts b/templates/slides/app/i18n/zh-CN.ts index f310f5d0e0..fd2f7992bb 100644 --- a/templates/slides/app/i18n/zh-CN.ts +++ b/templates/slides/app/i18n/zh-CN.ts @@ -206,6 +206,7 @@ const messages = { elementAnimations: "元素动画", tweaks: "微调", drawOnSlide: "在幻灯片上绘制", + addTextBox: "添加文本框", pinComments: "固定评论", pinCommentsDescription: "点击幻灯片上的位置来排队多个编辑,然后一次性发送。", diff --git a/templates/slides/app/i18n/zh-TW.ts b/templates/slides/app/i18n/zh-TW.ts index 87ce646137..67535f0ba5 100644 --- a/templates/slides/app/i18n/zh-TW.ts +++ b/templates/slides/app/i18n/zh-TW.ts @@ -201,6 +201,7 @@ const messages = { elementAnimations: "元素動畫", tweaks: "微調", drawOnSlide: "在幻燈片上繪製", + addTextBox: "新增文字方塊", pinComments: "固定評論", pinCommentsDescription: "點選幻燈片上的位置來排隊多個編輯,然後一次性傳送。", diff --git a/templates/slides/app/pages/DeckEditor.tsx b/templates/slides/app/pages/DeckEditor.tsx index 06caabf6b8..e3602f04d2 100644 --- a/templates/slides/app/pages/DeckEditor.tsx +++ b/templates/slides/app/pages/DeckEditor.tsx @@ -196,6 +196,7 @@ export default function DeckEditor() { const [tweaksOpen, setTweaksOpen] = useState(false); const [drawMode, setDrawMode] = useState(false); const [pinMode, setPinMode] = useState(false); + const [textBoxMode, setTextBoxMode] = useState(false); const [pendingComment, setPendingComment] = useState<{ quotedText: string; } | null>(null); @@ -816,6 +817,8 @@ export default function DeckEditor() { onToggleDrawMode={() => setDrawMode((v) => !v)} pinMode={pinMode} onTogglePinMode={() => setPinMode((v) => !v)} + textBoxMode={textBoxMode} + onToggleTextBoxMode={() => setTextBoxMode((v) => !v)} onDuplicateDeck={() => { const newId = `deck-${nanoid()}`; const optimistic = duplicateDeck(id, newId); @@ -992,6 +995,8 @@ export default function DeckEditor() { onExitDrawMode={() => setDrawMode(false)} pinMode={pinMode} onExitPinMode={() => setPinMode(false)} + textBoxMode={textBoxMode} + onExitTextBoxMode={() => setTextBoxMode(false)} slideId={currentSlide.id} slideTitle={(() => { const m = currentSlide.content?.match( From 26e9a2eeb9025f8b860fe154c0ff0b65f9c0b198 Mon Sep 17 00:00:00 2001 From: "Builder.io" Date: Fri, 24 Jul 2026 06:00:43 +0000 Subject: [PATCH 20/28] Improve text box placement and caret positioning in slides editor --- .../app/components/editor/SlideEditor.tsx | 30 ++++++++++++++++++- 1 file changed, 29 insertions(+), 1 deletion(-) diff --git a/templates/slides/app/components/editor/SlideEditor.tsx b/templates/slides/app/components/editor/SlideEditor.tsx index 08724bd1b3..b480df625d 100644 --- a/templates/slides/app/components/editor/SlideEditor.tsx +++ b/templates/slides/app/components/editor/SlideEditor.tsx @@ -1062,6 +1062,10 @@ export default function SlideEditor({ }, [overflowInfo, slide.id, dims.width, dims.height]); /** Marquee origin (viewport coords). Set on pointerdown. */ const marqueeOriginRef = useRef<{ x: number; y: number } | null>(null); + /** Set right before placing a text box so the click event that follows the + * placing pointerdown doesn't fall through to click-to-select/deselect + * logic and steal focus back off the freshly created box. */ + const suppressNextClickRef = useRef(false); /** * If the user pressed shift/cmd before starting a marquee, additive mode * preserves the existing selection on pointerup. @@ -1661,6 +1665,19 @@ export default function SlideEditor({ fmdSlide.appendChild(box); enterInlineEdit(box); + + // el.focus() alone doesn't reliably place the caret inside a freshly + // created contentEditable node across browsers — set it explicitly on + // the placeholder text so typing lands immediately. + const textNode = box.firstChild; + if (textNode) { + const range = document.createRange(); + range.setStart(textNode, textNode.textContent?.length ?? 0); + range.collapse(true); + const selection = window.getSelection(); + selection?.removeAllRanges(); + selection?.addRange(range); + } }, [enterInlineEdit], ); @@ -1675,9 +1692,15 @@ export default function SlideEditor({ const target = e.target as HTMLElement; if (textBoxMode && isHtmlSlide) { - if (!isSlideWhitespaceTarget(target, slideContent)) return; + // Unlike the marquee/select flow below, the text-box tool places a + // box on the very next click no matter what it lands on (mirroring + // Google Slides / PowerPoint). Gating this on "whitespace" left the + // tool silently stuck on when a click landed on existing text — + // editing that text worked, but the tool state never cleared, so an + // unrelated later click would unexpectedly drop a new box. e.preventDefault(); if (editingEl) exitInlineEdit(); + suppressNextClickRef.current = true; placeTextBoxAt(e.clientX, e.clientY); onExitTextBoxMode?.(); return; @@ -1855,6 +1878,11 @@ export default function SlideEditor({ const handleSlideClick = useCallback( (e: React.MouseEvent) => { + if (suppressNextClickRef.current) { + suppressNextClickRef.current = false; + return; + } + // If currently editing a block, clicks inside it are for the caret — // don't select/style-edit. if (editingEl?.contains(e.target as Node)) return; From 780047efd09edc5c806d75ff5963f32fc13522b9 Mon Sep 17 00:00:00 2001 From: "Builder.io" Date: Fri, 24 Jul 2026 06:27:29 +0000 Subject: [PATCH 21/28] Allow direct typing in empty text boxes without clicking text tool --- templates/slides/app/components/editor/SlideEditor.tsx | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/templates/slides/app/components/editor/SlideEditor.tsx b/templates/slides/app/components/editor/SlideEditor.tsx index b480df625d..a021a5690b 100644 --- a/templates/slides/app/components/editor/SlideEditor.tsx +++ b/templates/slides/app/components/editor/SlideEditor.tsx @@ -127,6 +127,9 @@ const RICH_BLOCK_TAGS = new Set(["P", "DIV", "BLOCKQUOTE", "LI", "UL", "OL"]); function isTextLeaf(el: HTMLElement): boolean { if (!el || el.tagName === "IMG") return false; if (el.classList.contains("fmd-img-placeholder")) return false; + // A user-placed text box stays editable even after its content is fully + // deleted, so an emptied box does not degrade into an unrecognized shape. + if (el.classList.contains("fmd-text-box")) return true; // Must contain some text if (!el.textContent?.trim()) return false; for (const child of Array.from(el.children)) { @@ -330,7 +333,9 @@ function buildStyleSnapshot( label: element.getAttribute("aria-label") || element.tagName.toLowerCase(), tagName: element.tagName.toLowerCase(), textPreview, - isText: element.tagName !== "IMG" && !!textPreview, + isText: + element.tagName !== "IMG" && + (!!textPreview || element.classList.contains("fmd-text-box")), isImage: element.tagName === "IMG", color: normalizedColor(computed.color), backgroundColor: normalizedColor(computed.backgroundColor), @@ -1653,6 +1658,7 @@ export default function SlideEditor({ } const box = document.createElement("div"); + box.className = "fmd-text-box"; box.style.position = "absolute"; box.style.left = String(x) + "px"; box.style.top = String(y) + "px"; From b8dd571569d0ebaeee413ad6cac21832da943010 Mon Sep 17 00:00:00 2001 From: "Builder.io" Date: Fri, 24 Jul 2026 06:50:30 +0000 Subject: [PATCH 22/28] Fix text box placement on scaled slides using percentage positioning --- .../app/components/editor/SlideEditor.tsx | 27 ++++++++++++++++--- 1 file changed, 23 insertions(+), 4 deletions(-) diff --git a/templates/slides/app/components/editor/SlideEditor.tsx b/templates/slides/app/components/editor/SlideEditor.tsx index a021a5690b..ff0a5be33b 100644 --- a/templates/slides/app/components/editor/SlideEditor.tsx +++ b/templates/slides/app/components/editor/SlideEditor.tsx @@ -1649,9 +1649,28 @@ export default function SlideEditor({ ".fmd-slide", ) as HTMLElement | null; if (!fmdSlide) return; + // .fmd-slide is often visually scaled (canvas zoom, and the autofit + // system that shrinks overflowing slides to fit) via a CSS transform. + // A transform doesn't change the element's own layout coordinate + // space, so a pixel offset computed from its on-screen rect would be + // scaled a second time once applied to a child. Percentages resolve + // against that untransformed layout box, so they land at the actual + // click point regardless of the current scale. const rect = fmdSlide.getBoundingClientRect(); - const x = Math.max(0, Math.round(clientX - rect.left)); - const y = Math.max(0, Math.round(clientY - rect.top)); + const xPct = + rect.width > 0 + ? Math.min( + 100, + Math.max(0, ((clientX - rect.left) / rect.width) * 100), + ) + : 0; + const yPct = + rect.height > 0 + ? Math.min( + 100, + Math.max(0, ((clientY - rect.top) / rect.height) * 100), + ) + : 0; if (getComputedStyle(fmdSlide).position === "static") { fmdSlide.style.position = "relative"; @@ -1660,8 +1679,8 @@ export default function SlideEditor({ const box = document.createElement("div"); box.className = "fmd-text-box"; box.style.position = "absolute"; - box.style.left = String(x) + "px"; - box.style.top = String(y) + "px"; + box.style.left = `${xPct}%`; + box.style.top = `${yPct}%`; box.style.width = "320px"; box.style.fontSize = "24px"; box.style.color = "#fff"; From dcbe67dce52661065052c8f610304a1e0f5a61be Mon Sep 17 00:00:00 2001 From: "Builder.io" Date: Fri, 24 Jul 2026 07:21:58 +0000 Subject: [PATCH 23/28] Fix bullet prefix detection in newly placed text boxes --- templates/slides/app/components/deck/SlideRenderer.tsx | 2 +- templates/slides/app/components/editor/bullet-editing.ts | 9 ++++++++- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/templates/slides/app/components/deck/SlideRenderer.tsx b/templates/slides/app/components/deck/SlideRenderer.tsx index 705e46dbf7..d5252d93e5 100644 --- a/templates/slides/app/components/deck/SlideRenderer.tsx +++ b/templates/slides/app/components/deck/SlideRenderer.tsx @@ -655,7 +655,7 @@ export function SlideInner({ canvasWidth={dims.width} canvasHeight={dims.height} fitKey={content} - className="h-full w-full" + className="slide-content h-full w-full" onOverflowChange={onOverflowChange} > diff --git a/templates/slides/app/components/editor/bullet-editing.ts b/templates/slides/app/components/editor/bullet-editing.ts index 82be612359..2b8cb9fcab 100644 --- a/templates/slides/app/components/editor/bullet-editing.ts +++ b/templates/slides/app/components/editor/bullet-editing.ts @@ -148,7 +148,14 @@ export function convertMarkdownPrefixToBullet(el: HTMLElement): boolean { const beforeCaretRange = document.createRange(); beforeCaretRange.selectNodeContents(el); beforeCaretRange.setEnd(caretRange.endContainer, caretRange.endOffset); - if (!MARKDOWN_BULLET_PREFIX.test(beforeCaretRange.toString())) return false; + // A freshly-placed text box seeds its content with a zero-width-space + // placeholder (see placeTextBoxAt) so it has a font to inherit before any + // real text exists. Strip it before testing so "- " typed as the very + // first characters is still recognized as a bullet prefix. + const beforeCaretText = beforeCaretRange + .toString() + .replace(new RegExp(ZERO_WIDTH_SPACE, "g"), ""); + if (!MARKDOWN_BULLET_PREFIX.test(beforeCaretText)) return false; beforeCaretRange.deleteContents(); const marker = document.createElement("span"); From 4115d03cc6092db33c788394d2f0f9259f67abb4 Mon Sep 17 00:00:00 2001 From: "Builder.io" Date: Fri, 24 Jul 2026 07:42:54 +0000 Subject: [PATCH 24/28] Add test for markdown bullet conversion with zero-width space --- .../components/editor/bullet-editing.test.tsx | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/templates/slides/app/components/editor/bullet-editing.test.tsx b/templates/slides/app/components/editor/bullet-editing.test.tsx index c8e3006954..8599a1aff6 100644 --- a/templates/slides/app/components/editor/bullet-editing.test.tsx +++ b/templates/slides/app/components/editor/bullet-editing.test.tsx @@ -7,6 +7,7 @@ import { insertBulletAfterCaret, isBulletList, isBulletRow, + ZERO_WIDTH_SPACE, } from "@/components/editor/bullet-editing"; const row = (text: string) => @@ -274,4 +275,20 @@ describe("markdown prefix autoformat", () => { expect(convertMarkdownPrefixToBullet(el)).toBe(false); expect(el.children.length).toBe(0); }); + + it("converts a leading dash typed in a fresh ZWS-seeded text box", () => { + document.body.innerHTML = + '
                                    '; + const root = document.querySelector(".slide-content") as HTMLElement; + const el = root.firstElementChild as HTMLElement; + const textNode = document.createTextNode(ZERO_WIDTH_SPACE + "- "); + el.appendChild(textNode); + placeCaret(textNode, textNode.length); + + expect(convertMarkdownPrefixToBullet(el)).toBe(true); + expect(isBulletList(el)).toBe(true); + const row = el.children[0] as HTMLElement; + expect(isBulletRow(row)).toBe(true); + expect(row.children[0].textContent).toBe("\u25CF"); + }); }); From f32061e20b66e900772a27dd461d94815edb0a15 Mon Sep 17 00:00:00 2001 From: "Builder.io" Date: Fri, 24 Jul 2026 08:20:02 +0000 Subject: [PATCH 25/28] Enable text box and shape deletion with Delete/Backspace keys --- .../app/components/editor/SlideEditor.tsx | 46 +++++++++++++++++++ 1 file changed, 46 insertions(+) diff --git a/templates/slides/app/components/editor/SlideEditor.tsx b/templates/slides/app/components/editor/SlideEditor.tsx index ff0a5be33b..4c1a50c891 100644 --- a/templates/slides/app/components/editor/SlideEditor.tsx +++ b/templates/slides/app/components/editor/SlideEditor.tsx @@ -1599,6 +1599,52 @@ export default function SlideEditor({ return () => window.removeEventListener("keydown", onKey); }, [multiSelection.size, editingEl, clearMultiSelection]); + // Delete/Backspace removes the selected shape/text box (single or + // multi-select) from the slide. Only active when something is selected for + // styling (not while inline-editing text, where Backspace should delete a + // character) and not while the browser focus is in an unrelated input. + useEffect(() => { + if (editingEl) return; + if (multiSelection.size === 0 && !selectedElementSelector) return; + const onKey = (e: KeyboardEvent) => { + if (e.key !== "Delete" && e.key !== "Backspace") return; + const active = document.activeElement; + const tag = active?.tagName; + if (tag === "INPUT" || tag === "TEXTAREA") return; + if (active instanceof HTMLElement && active.isContentEditable) return; + + const slideContent = getSlideContent(); + if (!slideContent) return; + e.preventDefault(); + + if (multiSelection.size > 0) { + for (const id of multiSelection) { + slideContent.querySelector(`[data-builder-id="${id}"]`)?.remove(); + } + clearMultiSelection(); + } else { + resolveSelectedElement()?.remove(); + clearSelectedElement(); + } + + const html = readCurrentSlideContentHtml(); + if (html !== null) onUpdateSlideRef.current({ content: html }); + syncSelectionToAppState(null); + }; + window.addEventListener("keydown", onKey); + return () => window.removeEventListener("keydown", onKey); + }, [ + editingEl, + multiSelection, + selectedElementSelector, + getSlideContent, + clearMultiSelection, + resolveSelectedElement, + clearSelectedElement, + readCurrentSlideContentHtml, + syncSelectionToAppState, + ]); + /** * Find the nearest meaningful "element" for multi-select from a click target. * Walks up to the closest [data-builder-id] inside the slide content. Skips From 94ba68205a285fee53d6cdcdaa60349f996356d0 Mon Sep 17 00:00:00 2001 From: "Builder.io" Date: Fri, 24 Jul 2026 08:33:32 +0000 Subject: [PATCH 26/28] Add drag-to-reposition for absolutely positioned slide elements --- .../app/components/editor/SlideEditor.tsx | 85 +++++++++++++++++++ 1 file changed, 85 insertions(+) diff --git a/templates/slides/app/components/editor/SlideEditor.tsx b/templates/slides/app/components/editor/SlideEditor.tsx index 4c1a50c891..3ea26ba874 100644 --- a/templates/slides/app/components/editor/SlideEditor.tsx +++ b/templates/slides/app/components/editor/SlideEditor.tsx @@ -18,6 +18,7 @@ import { import { appStateKeyForBrowserTab } from "@shared/app-state-tabs"; import { IconAlertTriangle, + IconArrowsMove, IconMaximize, IconZoomIn, IconZoomOut, @@ -605,9 +606,11 @@ function ImageSelectionOutline({ function ElementSelectionOutline({ rect, viewportRect, + onDragStart, }: { rect: DOMRect; viewportRect: DOMRect | null; + onDragStart?: (e: React.PointerEvent) => void; }) { const pad = 2; const handle = 7; @@ -644,6 +647,23 @@ function ElementSelectionOutline({ className={handleClass} style={{ right: -handle / 2, bottom: -handle / 2 }} /> + {onDragStart && ( + + + + )}
                                    ); @@ -2101,6 +2121,58 @@ export default function SlideEditor({ ], ); + /** + * Drag-to-reposition for the selected element. Only elements that are (or + * can safely become) absolutely positioned support this — repositioning an + * in-flow element would shift the rest of the slide's layout. Percentages + * (not px) are used for left/top so the drag stays accurate under canvas + * zoom and the autofit scale transform, matching placeTextBoxAt. + */ + const startElementDrag = useCallback( + (e: React.PointerEvent) => { + const element = resolveSelectedElement(); + if (!element) return; + const fmdSlide = element.closest(".fmd-slide") as HTMLElement | null; + if (!fmdSlide) return; + if (getComputedStyle(element).position === "static") return; + + e.preventDefault(); + e.stopPropagation(); + + const slideRect = fmdSlide.getBoundingClientRect(); + const elRect = element.getBoundingClientRect(); + const startXPct = + ((elRect.left - slideRect.left) / slideRect.width) * 100; + const startYPct = ((elRect.top - slideRect.top) / slideRect.height) * 100; + const startClientX = e.clientX; + const startClientY = e.clientY; + let moved = false; + + const onMove = (moveEvent: PointerEvent) => { + moved = true; + const dxPct = + ((moveEvent.clientX - startClientX) / slideRect.width) * 100; + const dyPct = + ((moveEvent.clientY - startClientY) / slideRect.height) * 100; + element.style.left = `${startXPct + dxPct}%`; + element.style.top = `${startYPct + dyPct}%`; + setSelectedElementRect(element.getBoundingClientRect()); + }; + + const onUp = () => { + window.removeEventListener("pointermove", onMove); + window.removeEventListener("pointerup", onUp); + if (!moved) return; + const html = readCurrentSlideContentHtml(); + if (html !== null) onUpdateSlideRef.current({ content: html }); + }; + + window.addEventListener("pointermove", onMove); + window.addEventListener("pointerup", onUp); + }, + [resolveSelectedElement, readCurrentSlideContentHtml], + ); + // --- Pending visual updates --- const [pendingUpdateCount, setPendingUpdateCount] = useState(0); @@ -2155,6 +2227,16 @@ export default function SlideEditor({ const slideElementSelected = !!selectedImg || !!editingEl || !!selectedStyleSnapshot; + // Dragging is only offered for elements that are already taken out of + // normal document flow (position: absolute/fixed) — our own placed text + // boxes, and any similarly-positioned shape. Repositioning an in-flow + // element (most AI-generated slide content) would reflow its siblings. + const selectedForDrag = + selectedElementRect && !editingEl ? resolveSelectedElement() : null; + const isSelectedElementDraggable = selectedForDrag + ? getComputedStyle(selectedForDrag).position !== "static" + : false; + return (
                                    )} From e2bdf55aec4547796ac09de50421a7a08b0682e5 Mon Sep 17 00:00:00 2001 From: agent-native Date: Fri, 24 Jul 2026 16:42:29 +0000 Subject: [PATCH 27/28] Created slide 3 with a grouped bullet block containing: --- .../app/components/editor/SlideEditor.tsx | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/templates/slides/app/components/editor/SlideEditor.tsx b/templates/slides/app/components/editor/SlideEditor.tsx index 3ea26ba874..09bf5f7651 100644 --- a/templates/slides/app/components/editor/SlideEditor.tsx +++ b/templates/slides/app/components/editor/SlideEditor.tsx @@ -2134,7 +2134,12 @@ export default function SlideEditor({ if (!element) return; const fmdSlide = element.closest(".fmd-slide") as HTMLElement | null; if (!fmdSlide) return; - if (getComputedStyle(element).position === "static") return; + // left/top only place an element at an absolute coordinate for + // position: absolute (or fixed). For position: relative they're an + // *offset* from the element's normal flow position instead, so + // treating a relative element as draggable here would make it jump by + // the wrong amount. Restrict dragging to elements already out of flow. + if (getComputedStyle(element).position !== "absolute") return; e.preventDefault(); e.stopPropagation(); @@ -2227,14 +2232,15 @@ export default function SlideEditor({ const slideElementSelected = !!selectedImg || !!editingEl || !!selectedStyleSnapshot; - // Dragging is only offered for elements that are already taken out of - // normal document flow (position: absolute/fixed) — our own placed text - // boxes, and any similarly-positioned shape. Repositioning an in-flow - // element (most AI-generated slide content) would reflow its siblings. + // Dragging is only offered for elements with position: absolute — our own + // placed text boxes, and any similarly-positioned shape. left/top on a + // position: relative element is an offset from its normal flow position + // rather than an absolute coordinate, so treating "not static" as + // draggable would move relative-positioned elements by the wrong amount. const selectedForDrag = selectedElementRect && !editingEl ? resolveSelectedElement() : null; const isSelectedElementDraggable = selectedForDrag - ? getComputedStyle(selectedForDrag).position !== "static" + ? getComputedStyle(selectedForDrag).position === "absolute" : false; return ( From 4de8aec3a184b6b58bb359e44be57f74f54f0672 Mon Sep 17 00:00:00 2001 From: "Builder.io" Date: Fri, 24 Jul 2026 17:49:27 +0000 Subject: [PATCH 28/28] Internationalize drag-to-move tooltip and remove unused class --- templates/slides/app/components/deck/SlideRenderer.tsx | 2 +- templates/slides/app/components/editor/SlideEditor.tsx | 3 ++- templates/slides/app/i18n/ar-SA.ts | 1 + templates/slides/app/i18n/de-DE.ts | 1 + templates/slides/app/i18n/en-US.ts | 1 + templates/slides/app/i18n/es-ES.ts | 1 + templates/slides/app/i18n/fr-FR.ts | 1 + templates/slides/app/i18n/hi-IN.ts | 1 + templates/slides/app/i18n/ja-JP.ts | 1 + templates/slides/app/i18n/ko-KR.ts | 1 + templates/slides/app/i18n/pt-BR.ts | 1 + templates/slides/app/i18n/zh-CN.ts | 1 + templates/slides/app/i18n/zh-TW.ts | 1 + 13 files changed, 14 insertions(+), 2 deletions(-) diff --git a/templates/slides/app/components/deck/SlideRenderer.tsx b/templates/slides/app/components/deck/SlideRenderer.tsx index d5252d93e5..705e46dbf7 100644 --- a/templates/slides/app/components/deck/SlideRenderer.tsx +++ b/templates/slides/app/components/deck/SlideRenderer.tsx @@ -655,7 +655,7 @@ export function SlideInner({ canvasWidth={dims.width} canvasHeight={dims.height} fitKey={content} - className="slide-content h-full w-full" + className="h-full w-full" onOverflowChange={onOverflowChange} > diff --git a/templates/slides/app/components/editor/SlideEditor.tsx b/templates/slides/app/components/editor/SlideEditor.tsx index 09bf5f7651..adc5ee612c 100644 --- a/templates/slides/app/components/editor/SlideEditor.tsx +++ b/templates/slides/app/components/editor/SlideEditor.tsx @@ -612,6 +612,7 @@ function ElementSelectionOutline({ viewportRect: DOMRect | null; onDragStart?: (e: React.PointerEvent) => void; }) { + const t = useT(); const pad = 2; const handle = 7; const handleClass = @@ -650,7 +651,7 @@ function ElementSelectionOutline({ {onDragStart && (