Skip to content

Commit 6899834

Browse files
add modify ability to header (#128)
1 parent 9ee29cd commit 6899834

9 files changed

Lines changed: 609 additions & 18 deletions

File tree

ARCHITECTURE.md

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -265,7 +265,7 @@ cd testing && npm run test:e2e
265265

266266
## Modify Mode
267267

268-
Modify mode (`modify-mode.js`) lets users make plain elements editable at runtime, without requiring `{.editable}` in the source document. Currently images are supported; additional element types can be added via the classifier registry.
268+
Modify mode (`modify-mode.js`) lets users make plain elements editable at runtime, without requiring `{.editable}` in the source document. Supported element types: plain images, `{.absolute}` images, plain videos, `{.absolute}` divs, and slide titles (`## heading`). Additional types can be added via the classifier registry.
269269

270270
### Lifecycle
271271

@@ -275,7 +275,7 @@ The toolbar "Modify" button calls `toggleModifyMode()`. When entering modify mod
275275
3. **Warn** elements receive `modify-mode-warn` (amber ring, not clickable); their reason string is stored in `_warnReasons` and readable via `getWarnReason(el)`
276276
4. A `slidechanged` listener re-runs classification on every slide navigation so rings stay current
277277

278-
Mode stays active after each element is clicked. The user dismisses it by clicking the toolbar button again.
278+
When the user clicks a valid element, `activate(el)` is called. If `activate` returns a truthy value, modify mode stays active (the classifier manages its own exit — e.g. the slide title classifier keeps the toolbar open until the user clicks away). Otherwise `exitModifyMode()` is called immediately.
279279

280280
### Classifier Registry
281281

@@ -291,7 +291,9 @@ New element types are added by calling `ModifyModeClassifier.register(classifier
291291
};
292292
},
293293

294-
// Required — called when the user clicks a valid element
294+
// Required — called when the user clicks a valid element.
295+
// Return true to keep modify mode active (classifier calls exitModifyMode itself).
296+
// Return falsy to exit modify mode automatically after activation.
295297
activate(el) {
296298
// stamp data-attributes, call setupImageWhenReady / setupDivWhenReady, etc.
297299
},

NEWS.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ All notable changes to the quarto-revealjs-editable extension will be documented
66

77
### Added
88

9+
- **Modify mode: slide title editing** - Slide `## ` headings are now activatable in modify mode. Click the highlighted title to open a formatting toolbar (bold, italic, underline, strikethrough, text color, background color) and edit the heading inline. Press Enter or click away to finish; the title can be re-activated to edit again. On save the `## Heading text` line is updated in place with inline formatting serialized to Quarto markdown (`**bold**`, `*italic*`, `~~strike~~`, `[text]{style='color: ...'}`).
910
- **Modify mode: video support** - Videos inserted with `![](video.mp4)` markdown syntax are now activatable in modify mode. Click a highlighted video to enable drag, resize, and rotate. On save the `](src)` reference is updated in-place with `{.absolute ...}` positioning, using the same approach as plain images.
1011
- **Modify mode: `{.absolute}` images** - Images previously saved with `{.absolute}` attributes (but without `{.editable}`) are now activatable in modify mode. Click a highlighted image to enable drag, resize, and rotate. On save the existing `](src){.absolute ...}` block is updated in-place using both the image src and position as a matching key.
1112
- **Modify mode: `{.absolute}` divs** - Divs previously saved with `{.absolute}` attributes (but without `{.editable}`) are now activatable in modify mode. Click a highlighted div to enable drag, resize, and rotate. On save the existing `{.absolute ...}` attribute block is updated in-place; no wrapper is added.

_extensions/editable/editable.css

Lines changed: 33 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -387,22 +387,25 @@
387387
/* Modify mode: valid elements get a persistent green ring */
388388
.modify-mode img.modify-mode-valid,
389389
.modify-mode video.modify-mode-valid,
390-
.modify-mode div.absolute.modify-mode-valid {
390+
.modify-mode div.absolute.modify-mode-valid,
391+
.modify-mode h2.modify-mode-valid {
391392
box-shadow: var(--editable-modify-valid-shadow);
392393
cursor: pointer;
393394
transition: box-shadow var(--editable-transition);
394395
}
395396

396397
.modify-mode img.modify-mode-valid:hover,
397398
.modify-mode video.modify-mode-valid:hover,
398-
.modify-mode div.absolute.modify-mode-valid:hover {
399+
.modify-mode div.absolute.modify-mode-valid:hover,
400+
.modify-mode h2.modify-mode-valid:hover {
399401
box-shadow: var(--editable-modify-valid-hover-shadow);
400402
}
401403

402404
/* Modify mode: elements that cannot be targeted get an amber warning ring (not clickable) */
403405
.modify-mode img.modify-mode-warn,
404406
.modify-mode video.modify-mode-warn,
405-
.modify-mode div.absolute.modify-mode-warn {
407+
.modify-mode div.absolute.modify-mode-warn,
408+
.modify-mode h2.modify-mode-warn {
406409
box-shadow: var(--editable-modify-warn-shadow);
407410
cursor: not-allowed;
408411
transition: box-shadow var(--editable-transition);
@@ -414,6 +417,17 @@
414417
box-shadow: var(--editable-modify-activated-shadow);
415418
}
416419

420+
/* Active heading editor */
421+
h2.editable-heading-active {
422+
outline: 2px solid var(--editable-highlight-color, #4A90D9);
423+
outline-offset: 3px;
424+
cursor: text;
425+
}
426+
427+
h2[data-editable-modified-heading="true"] {
428+
box-shadow: var(--editable-modify-activated-shadow);
429+
}
430+
417431
/* Modify mode panel */
418432
.toolbar-panel-modify {
419433
display: flex;
@@ -567,6 +581,22 @@
567581
}
568582

569583
/* Quill toolbar hosted in the top-bar text panel */
584+
/* Heading format toolbar buttons (bold/italic/etc. for modify-mode heading editing) */
585+
.toolbar-panel-text .heading-edit-toolbar button {
586+
background: none;
587+
border: none;
588+
color: #333;
589+
cursor: pointer;
590+
padding: 4px 8px;
591+
border-radius: 4px;
592+
font-size: 13px;
593+
line-height: 1;
594+
}
595+
596+
.toolbar-panel-text .heading-edit-toolbar button:hover {
597+
background: rgba(0, 0, 0, 0.08);
598+
}
599+
570600
.toolbar-panel-text .ql-toolbar.ql-snow {
571601
position: static !important;
572602
bottom: auto !important;

_extensions/editable/editable.js

Lines changed: 222 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -16407,6 +16407,222 @@ ${fence}`;
1640716407
return chunks.join("");
1640816408
}
1640916409
});
16410+
function headingHtmlToMarkdown(html) {
16411+
let text = html;
16412+
text = text.replace(
16413+
/<span[^>]*style="[^"]*background-color:\s*([^;"]+)[^"]*"[^>]*>([\s\S]*?)<\/span>/gi,
16414+
(_, colorVal, content) => `[${content}]{style='background-color: ${getBrandColorOutput(colorVal.trim())}'}`
16415+
);
16416+
text = text.replace(
16417+
/<span[^>]*style="[^"]*(?<!background-)color:\s*([^;"]+)[^"]*"[^>]*>([\s\S]*?)<\/span>/gi,
16418+
(_, colorVal, content) => {
16419+
if (colorVal.trim().toLowerCase() === "inherit")
16420+
return content;
16421+
return `[${content}]{style='color: ${getBrandColorOutput(colorVal.trim())}'}`;
16422+
}
16423+
);
16424+
text = text.replace(
16425+
/<font[^>]*\bcolor="([^"]+)"[^>]*>([\s\S]*?)<\/font>/gi,
16426+
(_, colorVal, content) => `[${content}]{style='color: ${getBrandColorOutput(colorVal.trim())}'}`
16427+
);
16428+
return text.replace(/<strong[^>]*>([\s\S]*?)<\/strong>/gi, "**$1**").replace(/<b[^>]*>([\s\S]*?)<\/b>/gi, "**$1**").replace(/<em[^>]*>([\s\S]*?)<\/em>/gi, "*$1*").replace(/<i[^>]*>([\s\S]*?)<\/i>/gi, "*$1*").replace(/<s[^>]*>([\s\S]*?)<\/s>/gi, "~~$1~~").replace(/<strike[^>]*>([\s\S]*?)<\/strike>/gi, "~~$1~~").replace(/<[^>]+>/g, "").replace(/&lt;/g, "<").replace(/&gt;/g, ">").replace(/&amp;/g, "&").replace(/&quot;/g, '"').replace(/&#39;/g, "'").replace(/&nbsp;/g, " ").trim();
16429+
}
16430+
function buildColorPicker(execCmd, title, pickerClass, presetColors) {
16431+
let savedRange = null;
16432+
const saveSelection = () => {
16433+
const sel = window.getSelection();
16434+
if (sel && sel.rangeCount)
16435+
savedRange = sel.getRangeAt(0).cloneRange();
16436+
};
16437+
const restoreSelection = () => {
16438+
if (!savedRange)
16439+
return;
16440+
const sel = window.getSelection();
16441+
sel.removeAllRanges();
16442+
sel.addRange(savedRange);
16443+
};
16444+
const isForeground = execCmd === "foreColor";
16445+
const iconSvg = isForeground ? '<svg viewbox="0 0 18 18"><line class="ql-color-label ql-stroke ql-transparent" x1="3" x2="15" y1="15" y2="15"/><polyline class="ql-stroke" points="5.5 11 9 3 12.5 11"/><line class="ql-stroke" x1="11.63" x2="6.38" y1="9" y2="9"/></svg>' : '<svg viewbox="0 0 18 18"><g class="ql-fill ql-color-label"><polygon points="6 6.868 6 6 5 6 5 7 5.942 7 6 6.868"/><rect height="1" width="1" x="4" y="4"/><polygon points="6.817 5 6 5 6 6 6.38 6 6.817 5"/><rect height="1" width="1" x="2" y="6"/><rect height="1" width="1" x="3" y="5"/><polygon points="11.183 5 11.62 6 12 6 12 5 11.183 5"/><rect height="1" width="1" x="11" y="4"/><polygon points="12 6.868 12.058 7 13 7 13 6 12 6 12 6.868"/><rect height="1" width="1" x="13" y="6"/><rect height="1" width="1" x="14" y="4"/><polygon points="14 5 13.367 5 13.82 6 14 6 14 5"/><rect height="1" width="1" x="14" y="7"/><rect height="1" width="1" x="14" y="2"/><rect height="1" width="1" x="13" y="3"/><polygon points="12 3.132 12 3 11 3 11 4 11.183 4 12 3.132"/><rect height="1" width="1" x="10" y="2"/><rect height="1" width="1" x="9" y="3"/><rect height="1" width="1" x="8" y="2"/><rect height="1" width="1" x="7" y="3"/><rect height="1" width="1" x="6" y="2"/><rect height="1" width="1" x="5" y="3"/><polygon points="3.917 5 4 5 4 6 4.075 6 3.917 5"/><rect height="1" width="1" x="3" y="7"/><rect height="1" width="1" x="2" y="4"/></g><rect class="ql-stroke" height="12" rx="1" ry="1" width="12" x="3" y="3"/></svg>';
16446+
const label = document.createElement("span");
16447+
label.className = "ql-picker-label";
16448+
label.title = title;
16449+
label.innerHTML = iconSvg;
16450+
const options = document.createElement("span");
16451+
options.className = "ql-picker-options";
16452+
options.style.display = "none";
16453+
const addItem = (value, bg) => {
16454+
const item = document.createElement("span");
16455+
item.className = "ql-picker-item";
16456+
item.dataset.value = value;
16457+
if (bg)
16458+
item.style.backgroundColor = bg;
16459+
options.appendChild(item);
16460+
return item;
16461+
};
16462+
addItem("unset");
16463+
for (const color of presetColors)
16464+
addItem(color, color);
16465+
const customInput = document.createElement("input");
16466+
customInput.type = "color";
16467+
customInput.style.cssText = "position:absolute;visibility:hidden;width:0;height:0;";
16468+
const updateSwatch = (color) => {
16469+
const swatchEl = label.querySelector(".ql-color-label");
16470+
if (swatchEl)
16471+
swatchEl.style[isForeground ? "stroke" : "fill"] = color || "";
16472+
};
16473+
customInput.addEventListener("input", () => {
16474+
restoreSelection();
16475+
document.execCommand(execCmd, false, customInput.value);
16476+
updateSwatch(customInput.value);
16477+
});
16478+
addItem("custom");
16479+
const picker = document.createElement("span");
16480+
picker.className = `ql-picker ql-color-picker ${pickerClass}`;
16481+
picker.appendChild(label);
16482+
picker.appendChild(options);
16483+
picker.appendChild(customInput);
16484+
label.addEventListener("mousedown", (e) => {
16485+
e.preventDefault();
16486+
saveSelection();
16487+
const isOpen = picker.classList.contains("ql-expanded");
16488+
picker.closest(".heading-edit-toolbar")?.querySelectorAll(".ql-expanded").forEach((p) => {
16489+
p.classList.remove("ql-expanded");
16490+
p.querySelector(".ql-picker-options").style.display = "none";
16491+
});
16492+
if (!isOpen) {
16493+
picker.classList.add("ql-expanded");
16494+
options.style.display = "flex";
16495+
}
16496+
});
16497+
options.addEventListener("mousedown", (e) => {
16498+
e.preventDefault();
16499+
const item = e.target.closest(".ql-picker-item");
16500+
if (!item)
16501+
return;
16502+
picker.classList.remove("ql-expanded");
16503+
options.style.display = "none";
16504+
const value = item.dataset.value;
16505+
if (value === "custom") {
16506+
customInput.click();
16507+
return;
16508+
}
16509+
restoreSelection();
16510+
if (value === "unset") {
16511+
document.execCommand(execCmd, false, "inherit");
16512+
updateSwatch("");
16513+
} else {
16514+
document.execCommand(execCmd, false, value);
16515+
updateSwatch(value);
16516+
}
16517+
});
16518+
return picker;
16519+
}
16520+
function buildHeadingToolbar(h2) {
16521+
const toolbar = document.createElement("div");
16522+
toolbar.className = "heading-edit-toolbar quill-toolbar-container ql-toolbar ql-snow";
16523+
const buttons = [
16524+
{ command: "bold", label: "B", title: "Bold", style: "font-weight:bold" },
16525+
{ command: "italic", label: "I", title: "Italic", style: "font-style:italic" },
16526+
{ command: "underline", label: "U", title: "Underline", style: "text-decoration:underline" },
16527+
{ command: "strikeThrough", label: "S", title: "Strikethrough", style: "text-decoration:line-through" }
16528+
];
16529+
for (const { command, label, title, style } of buttons) {
16530+
const btn = document.createElement("button");
16531+
btn.type = "button";
16532+
btn.textContent = label;
16533+
btn.title = title;
16534+
btn.style.cssText = style;
16535+
btn.addEventListener("mousedown", (e) => {
16536+
e.preventDefault();
16537+
document.execCommand(command);
16538+
});
16539+
toolbar.appendChild(btn);
16540+
}
16541+
const presetColors = getColorPalette();
16542+
toolbar.appendChild(buildColorPicker("foreColor", "Text color", "ql-color", presetColors));
16543+
toolbar.appendChild(buildColorPicker("backColor", "Background color", "ql-background", presetColors));
16544+
const onDocMouseDown = (e) => {
16545+
if (!toolbar.contains(e.target)) {
16546+
toolbar.querySelectorAll(".ql-expanded").forEach((p) => {
16547+
p.classList.remove("ql-expanded");
16548+
p.querySelector(".ql-picker-options").style.display = "none";
16549+
});
16550+
}
16551+
};
16552+
document.addEventListener("mousedown", onDocMouseDown);
16553+
toolbar._cleanup = () => document.removeEventListener("mousedown", onDocMouseDown);
16554+
return toolbar;
16555+
}
16556+
ModifyModeClassifier.register({
16557+
label: "Slide titles",
16558+
classify(slideEl) {
16559+
const h2 = slideEl.querySelector("h2");
16560+
if (!h2)
16561+
return { valid: [], warn: [] };
16562+
if (h2.classList.contains("editable-heading-active"))
16563+
return { valid: [], warn: [] };
16564+
return { valid: [h2], warn: [] };
16565+
},
16566+
activate(h2) {
16567+
if (h2.classList.contains("editable-heading-active"))
16568+
return true;
16569+
h2.dataset.editableModifiedHeading = "true";
16570+
h2.dataset.editableModifiedSlide = String(Reveal.getState().indexh);
16571+
h2.dataset.editableModifiedOriginalHtml = h2.innerHTML;
16572+
h2.classList.add("editable-heading-active");
16573+
exitModifyMode({ resetPanel: false });
16574+
h2.contentEditable = "true";
16575+
h2.focus();
16576+
const range = document.createRange();
16577+
range.selectNodeContents(h2);
16578+
const sel = window.getSelection();
16579+
sel.removeAllRanges();
16580+
sel.addRange(range);
16581+
const toolbar = buildHeadingToolbar(h2);
16582+
const textPanel = document.querySelector(".toolbar-panel-text");
16583+
if (textPanel)
16584+
textPanel.appendChild(toolbar);
16585+
showRightPanel("text");
16586+
const onKeyDown = (e) => {
16587+
if (e.key === "Enter") {
16588+
e.preventDefault();
16589+
h2.blur();
16590+
}
16591+
if (e.key === "Escape") {
16592+
e.preventDefault();
16593+
h2.innerHTML = h2.dataset.editableModifiedOriginalHtml;
16594+
h2.blur();
16595+
}
16596+
};
16597+
h2.addEventListener("keydown", onKeyDown);
16598+
h2.addEventListener("blur", () => {
16599+
h2.removeEventListener("keydown", onKeyDown);
16600+
h2.contentEditable = "false";
16601+
h2.classList.remove("editable-heading-active");
16602+
toolbar._cleanup?.();
16603+
toolbar.remove();
16604+
showRightPanel("default");
16605+
}, { once: true });
16606+
return true;
16607+
},
16608+
serialize(text) {
16609+
const headings = Array.from(
16610+
document.querySelectorAll('h2[data-editable-modified-heading="true"]')
16611+
);
16612+
if (!headings.length)
16613+
return text;
16614+
const chunks = splitIntoSlideChunks(text);
16615+
for (const h2 of headings) {
16616+
const slideIndex = parseInt(h2.dataset.editableModifiedSlide ?? "0", 10);
16617+
const chunkIndex = getQmdHeadingIndex(slideIndex) + 1;
16618+
if (chunkIndex >= chunks.length)
16619+
continue;
16620+
const newText = headingHtmlToMarkdown(h2.innerHTML);
16621+
chunks[chunkIndex] = chunks[chunkIndex].replace(/^## .*/m, `## ${newText}`);
16622+
}
16623+
return chunks.join("");
16624+
}
16625+
});
1641016626
function classifyElements() {
1641116627
const reveal = document.querySelector(".reveal");
1641216628
const currentSlide = reveal?.querySelector(".slides section.present:not(.slide-background)") ?? reveal;
@@ -16462,7 +16678,7 @@ ${fence}`;
1646216678
applyClassification();
1646316679
Reveal.on("slidechanged", applyClassification);
1646416680
}
16465-
function exitModifyMode() {
16681+
function exitModifyMode({ resetPanel = true } = {}) {
1646616682
_active = false;
1646716683
document.querySelector(".reveal")?.classList.remove(ROOT_CLASS);
1646816684
Reveal.off("slidechanged", applyClassification);
@@ -16476,7 +16692,8 @@ ${fence}`;
1647616692
el.classList.remove(VALID_CLASS, WARN_CLASS);
1647716693
});
1647816694
document.querySelector(".toolbar-modify")?.classList.remove("active");
16479-
showRightPanel("default");
16695+
if (resetPanel)
16696+
showRightPanel("default");
1648016697
}
1648116698
function toggleModifyMode() {
1648216699
if (_active) {
@@ -16489,8 +16706,9 @@ ${fence}`;
1648916706
function onValidElementClick(e, classifier) {
1649016707
e.stopPropagation();
1649116708
const el = e.currentTarget;
16492-
classifier.activate(el);
16493-
exitModifyMode();
16709+
const stayActive = classifier.activate(el);
16710+
if (!stayActive)
16711+
exitModifyMode();
1649416712
}
1649516713

1649616714
// src/io.js

0 commit comments

Comments
 (0)