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 f020759347..adc5ee612c 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, @@ -35,6 +36,13 @@ 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 { + convertMarkdownPrefixToBullet, + findEnclosingList, + insertBulletAfterCaret, + isBulletList, + ZERO_WIDTH_SPACE, +} from "@/components/editor/bullet-editing"; import { Button } from "@/components/ui/button"; import { Tooltip, @@ -120,6 +128,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)) { @@ -172,7 +183,14 @@ function findSmartBlock( ): HTMLElement | null { let el: HTMLElement | null = target; while (el && root.contains(el)) { - if (isTextLeaf(el)) return el; + if (isTextLeaf(el)) { + // A list item (native
  • 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 // rows). If that container is a smart group, use IT as the block so // the user gets multi-chunk editing of everything inside. @@ -200,13 +218,41 @@ 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, ""); } +/** + * 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 { const parsed = Number.parseFloat(value); return Number.isFinite(parsed) ? parsed : 0; @@ -288,7 +334,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), @@ -394,6 +442,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 */ @@ -553,10 +606,13 @@ function ImageSelectionOutline({ function ElementSelectionOutline({ rect, viewportRect, + onDragStart, }: { rect: DOMRect; viewportRect: DOMRect | null; + onDragStart?: (e: React.PointerEvent) => void; }) { + const t = useT(); const pad = 2; const handle = 7; const handleClass = @@ -592,6 +648,23 @@ function ElementSelectionOutline({ className={handleClass} style={{ right: -handle / 2, bottom: -handle / 2 }} /> + {onDragStart && ( + + + + )} ); @@ -737,6 +810,8 @@ export default function SlideEditor({ onExitDrawMode, pinMode, onExitPinMode, + textBoxMode, + onExitTextBoxMode, slideId, slideTitle, deckId, @@ -1013,6 +1088,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. @@ -1203,7 +1282,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]); @@ -1218,8 +1304,21 @@ 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)) || + 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 + // 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(); @@ -1229,12 +1328,34 @@ 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; + // 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; + } + if (!isMultiLineLeaf) { e.preventDefault(); exitInlineEdit(); @@ -1243,7 +1364,13 @@ export default function SlideEditor({ }; window.addEventListener("keydown", onKey, true); return () => window.removeEventListener("keydown", onKey, true); - }, [exitInlineEdit, editingEl]); + }, [ + exitInlineEdit, + editingEl, + captureInlineEditDraft, + slide.id, + getSlideContent, + ]); // Click-outside: exit inline edit mode useEffect(() => { @@ -1493,6 +1620,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 @@ -1537,16 +1710,95 @@ export default function SlideEditor({ [], ); + const placeTextBoxAt = useCallback( + (clientX: number, clientY: number) => { + const fmdSlide = containerRef.current?.querySelector( + ".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 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"; + } + + const box = document.createElement("div"); + box.className = "fmd-text-box"; + box.style.position = "absolute"; + box.style.left = `${xPct}%`; + box.style.top = `${yPct}%`; + 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); + + // 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], + ); + // --- 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) { + // 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; + } + 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). @@ -1575,6 +1827,11 @@ export default function SlideEditor({ multiSelection, applyMultiSelection, clearSelectedElement, + textBoxMode, + isHtmlSlide, + exitInlineEdit, + placeTextBoxAt, + onExitTextBoxMode, ], ); @@ -1713,6 +1970,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; @@ -1750,7 +2012,27 @@ export default function SlideEditor({ showImageOverlay(target); - // Send style-editing postMessage with a unique selector for the clicked element + // 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; @@ -1772,6 +2054,9 @@ export default function SlideEditor({ clearMultiSelection, clearSelectedElement, selectElementForStyling, + readOnly, + isHtmlSlide, + enterInlineEdit, ], ); @@ -1837,6 +2122,63 @@ 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; + // 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(); + + 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); @@ -1891,6 +2233,17 @@ export default function SlideEditor({ const slideElementSelected = !!selectedImg || !!editingEl || !!selectedStyleSnapshot; + // 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 === "absolute" + : false; + return (
      )} 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..8599a1aff6 --- /dev/null +++ b/templates/slides/app/components/editor/bullet-editing.test.tsx @@ -0,0 +1,294 @@ +// @vitest-environment happy-dom +import { beforeEach, describe, expect, it } from "vitest"; + +import { + convertMarkdownPrefixToBullet, + findEnclosingList, + insertBulletAfterCaret, + isBulletList, + isBulletRow, + ZERO_WIDTH_SPACE, +} from "@/components/editor/bullet-editing"; + +const row = (text: string) => + `
      ${text}
      `; + +const LIST_HTML = `
      + ${row("First point")} + ${row("Second point")} + ${row("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 placeCaret(node: Node, offset: number) { + const sel = window.getSelection(); + const range = document.createRange(); + range.setStart(node, offset); + 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 r of Array.from(list.children)) { + expect(isBulletRow(r 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; + const textNode = thirdText.firstChild as Text; + placeCaret(textNode, textNode.length); + + expect(list.children.length).toBe(3); + expect(insertBulletAfterCaret(list)).toBe(true); + expect(list.children.length).toBe(4); + + const newRow = list.children[3] as HTMLElement; + expect(newRow.children[0].textContent).toBe("\u25CF"); + expect((newRow.children[1].textContent ?? "").replace(/\u200B/g, "")).toBe( + "", + ); + 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; + const textNode = secondText.firstChild as Text; + placeCaret(textNode, "Second".length); + + 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"); + }); + + 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 = + '
      ' + + '
      \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); + }); +}); + +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); + }); + + 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"); + }); +}); 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..2b8cb9fcab --- /dev/null +++ b/templates/slides/app/components/editor/bullet-editing.ts @@ -0,0 +1,350 @@ +/** + * 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 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 { + 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 + * 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; +} + +/** 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); + // 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"); + 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; + let placeholderZws: Text | null = null; + if (!restFirstChild) { + placeholderZws = document.createTextNode(ZERO_WIDTH_SPACE); + textSpan.appendChild(placeholderZws); + } + + 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. + range.setStart(placeholderZws as Text, 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 + * trapped inside one item. + */ +export function findEnclosingList( + el: HTMLElement, + root: HTMLElement, +): HTMLElement | null { + let node: HTMLElement | null = el; + while (node && root.contains(node)) { + 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(parentEl) >= 2) return parentEl; + node = parentEl; + } + 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 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: DocumentFragment | null): void { + const marker = + row.firstElementChild && isBulletMarker(row.firstElementChild) + ? (row.firstElementChild as HTMLElement) + : null; + const container = rowTextContainer(row, marker); + + // Clear existing text content, preserving the marker glyph. + if (container !== row) { + container.replaceChildren(); + } else { + while (marker?.nextSibling) marker.nextSibling.remove(); + if (!marker) row.replaceChildren(); + } + + const firstTailNode = tail?.firstChild ?? null; + if (tail && firstTailNode) container.appendChild(tail); + + const sel = window.getSelection(); + if (!sel) return; + const range = document.createRange(); + 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. 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(); + if (!sel || sel.rangeCount === 0) return false; + const range = sel.getRangeAt(0); + 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; + while (node && node !== list) { + if (node.parentNode === list) { + row = node as HTMLElement; + break; + } + node = node.parentNode; + } + if (!row) return false; + + 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(); + // 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; + 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; +} 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..ce26695a2e 100644 --- a/templates/slides/app/i18n/ar-SA.ts +++ b/templates/slides/app/i18n/ar-SA.ts @@ -120,6 +120,7 @@ const messages = { uploadAttachedFailed: "Não foi possível enviar o arquivo anexado.", uploadFailed: "Falha no envio", doubleClickEdit: "Clique duas vezes em qualquer texto para editar", + dragToMove: "اسحب للتحريك", aiEditing: "IA editando", startTypingCommands: "Comece a digitar… ou pressione / para comandos", pinDropHint: "Clique em qualquer lugar para soltar um pin de comentário", @@ -213,6 +214,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..50ed0fa611 100644 --- a/templates/slides/app/i18n/de-DE.ts +++ b/templates/slides/app/i18n/de-DE.ts @@ -124,6 +124,7 @@ const messages = { "Die angehängte Datei konnte nicht hochgeladen werden.", uploadFailed: "Upload fehlgeschlagen", doubleClickEdit: "Doppelklicke auf Text zum Bearbeiten", + dragToMove: "Ziehen zum Verschieben", aiEditing: "KI bearbeitet", startTypingCommands: "Beginne zu tippen… oder drücke / für Befehle", pinDropHint: "Klicke irgendwo, um eine Kommentar-Pin zu setzen", @@ -214,6 +215,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..622b911963 100644 --- a/templates/slides/app/i18n/en-US.ts +++ b/templates/slides/app/i18n/en-US.ts @@ -121,6 +121,7 @@ const messages = { uploadAttachedFailed: "Could not upload the attached file.", uploadFailed: "Upload failed", doubleClickEdit: "Double-click any text to edit", + dragToMove: "Drag to move", aiEditing: "AI editing", startTypingCommands: "Start typing… or press / for commands", pinDropHint: "Click anywhere to drop a comment pin", @@ -210,6 +211,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..5b24f5fa54 100644 --- a/templates/slides/app/i18n/es-ES.ts +++ b/templates/slides/app/i18n/es-ES.ts @@ -122,6 +122,7 @@ const messages = { uploadAttachedFailed: "No se pudo subir el archivo adjunto.", uploadFailed: "Error al subir", doubleClickEdit: "Haz doble clic en cualquier texto para editarlo", + dragToMove: "Arrastra para mover", aiEditing: "IA editando", startTypingCommands: "Empieza a escribir… o pulsa / para comandos", pinDropHint: @@ -216,6 +217,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..6ae9b734dc 100644 --- a/templates/slides/app/i18n/fr-FR.ts +++ b/templates/slides/app/i18n/fr-FR.ts @@ -123,6 +123,7 @@ const messages = { uploadAttachedFailed: "Impossible d’envoyer le fichier joint.", uploadFailed: "Échec de l’envoi", doubleClickEdit: "Double-cliquez sur un texte pour le modifier", + dragToMove: "Faites glisser pour déplacer", aiEditing: "IA en édition", startTypingCommands: "Commencez à écrire… ou appuyez sur / pour les commandes", @@ -218,6 +219,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..bfc4f2e210 100644 --- a/templates/slides/app/i18n/hi-IN.ts +++ b/templates/slides/app/i18n/hi-IN.ts @@ -120,6 +120,7 @@ const messages = { uploadAttachedFailed: "Não foi possível enviar o arquivo anexado.", uploadFailed: "Falha no envio", doubleClickEdit: "Clique duas vezes em qualquer texto para editar", + dragToMove: "स्थानांतरित करने के लिए खींचें", aiEditing: "IA editando", startTypingCommands: "Comece a digitar… ou pressione / para comandos", pinDropHint: "Clique em qualquer lugar para soltar um pin de comentário", @@ -209,6 +210,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..00b56d42ea 100644 --- a/templates/slides/app/i18n/ja-JP.ts +++ b/templates/slides/app/i18n/ja-JP.ts @@ -121,6 +121,7 @@ const messages = { uploadAttachedFailed: "添付ファイルをアップロードできませんでした。", uploadFailed: "アップロード失敗", doubleClickEdit: "テキストをダブルクリックして編集", + dragToMove: "ドラッグして移動", aiEditing: "AIが編集中", startTypingCommands: "入力を開始…または / でコマンド", pinDropHint: "任意の場所をクリックしてコメントピンを追加", @@ -210,6 +211,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..eb515985a8 100644 --- a/templates/slides/app/i18n/ko-KR.ts +++ b/templates/slides/app/i18n/ko-KR.ts @@ -121,6 +121,7 @@ const messages = { uploadAttachedFailed: "添付ファイルをアップロードできませんでした。", uploadFailed: "アップロード失敗", doubleClickEdit: "テキストをダブルクリックして編集", + dragToMove: "드래그하여 이동", aiEditing: "AIが編集中", startTypingCommands: "入力を開始…または / でコマンド", pinDropHint: "任意の場所をクリックしてコメントピンを追加", @@ -210,6 +211,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..34d4c9f034 100644 --- a/templates/slides/app/i18n/pt-BR.ts +++ b/templates/slides/app/i18n/pt-BR.ts @@ -121,6 +121,7 @@ const messages = { uploadAttachedFailed: "Não foi possível enviar o arquivo anexado.", uploadFailed: "Falha no envio", doubleClickEdit: "Clique duas vezes em qualquer texto para editar", + dragToMove: "Arraste para mover", aiEditing: "IA editando", startTypingCommands: "Comece a digitar… ou pressione / para comandos", pinDropHint: "Clique em qualquer lugar para soltar um pin de comentário", @@ -211,6 +212,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..a905745731 100644 --- a/templates/slides/app/i18n/zh-CN.ts +++ b/templates/slides/app/i18n/zh-CN.ts @@ -118,6 +118,7 @@ const messages = { uploadAttachedFailed: "添付ファイルをアップロードできませんでした。", uploadFailed: "アップロード失敗", doubleClickEdit: "テキストをダブルクリックして編集", + dragToMove: "拖动以移动", aiEditing: "AIが編集中", startTypingCommands: "入力を開始…または / でコマンド", pinDropHint: "任意の場所をクリックしてコメントピンを追加", @@ -206,6 +207,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..1aebb02b52 100644 --- a/templates/slides/app/i18n/zh-TW.ts +++ b/templates/slides/app/i18n/zh-TW.ts @@ -116,6 +116,7 @@ const messages = { uploadAttachedFailed: "無法上傳附件。", uploadFailed: "上傳失敗", doubleClickEdit: "按兩下文字以編輯", + dragToMove: "拖曳以移動", aiEditing: "AI 正在編輯", startTypingCommands: "開始輸入…或輸入 / 使用命令", pinDropHint: "點選任意位置以新增評論圖釘", @@ -201,6 +202,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( 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 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 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