Custom popup & custom action demo
Forum quote workflow
This example shows the popup layer being fully replaced: different structure, different styling, a top-right close control, and a custom Quote action that writes forum-style BBCode into a reply editor. The core still handles selection geometry, merging, popup placement, and clipboard copy.
Forum thread
Select text inside a post, then use Quote or Copy from the custom popup.
Response
Preview renders valid quotes visually. Code mode exposes the raw forum markup.
If the quote markup stays valid, the posted reply renders it as a forum quote. Broken quote tags are shown as raw text, just like a forum post with invalid BBCode.
Code used for this demo
The thread posts use the same shared core, but replace the default popup with a custom renderer. Quote inserts the selected text into the reply composer as forum BBCode, while Copy uses the built-in clipboard action.
<article class="forum-post" data-author="Lyra Vale">
<p class="forum-post__body">Select any part of this post.</p>
</article>
<textarea id="forum-response" aria-label="Forum response"></textarea>
<script src="/cleanselection.js"></script>
<script>
const postBody = document.querySelector('.forum-post__body');
const responseEditor = document.querySelector('#forum-response');
const author = postBody.closest('[data-author]').dataset.author;
function appendQuote(authorName, selectedText) {
const quote = `[quote author="${authorName}"]${selectedText.trim()}[/quote]`;
const separator = responseEditor.value.trim() ? '\n\n' : '';
responseEditor.value += `${separator}${quote}\n`;
responseEditor.dispatchEvent(new Event('input', { bubbles: true }));
responseEditor.scrollIntoView({ behavior: 'smooth', block: 'center' });
}
function createForumPopupRenderer(authorName) {
return {
create(context) {
let currentContext = context;
const { host, shadowRoot } = CleanSelection.createPopupShell('forum-popup-host');
const style = document.createElement('style');
style.textContent = `
.forum-popup {
min-width: 244px;
max-width: calc(100vw - 24px);
border: 1px solid #2b394a;
background: linear-gradient(180deg, #fff8ee, #f0e1c7);
color: #243244;
box-shadow: 8px 8px 0 rgba(43, 57, 74, 0.2);
}
.forum-popup__frame {
display: grid;
gap: 14px;
padding: 14px;
}
.forum-popup__topline {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.forum-popup__label {
color: #754126;
font-size: 0.76rem;
font-weight: 800;
letter-spacing: 0.12em;
text-transform: uppercase;
}
.forum-popup__close {
appearance: none;
border: 0;
display: grid;
place-items: center;
width: 1.4rem;
height: 1.4rem;
padding: 0;
background: transparent;
color: #435164;
cursor: pointer;
}
.forum-popup__close svg {
width: 0.9rem;
height: 0.9rem;
stroke: currentColor;
stroke-width: 2.4;
stroke-linecap: round;
}
.forum-popup__actions {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px;
}
.forum-popup__button {
appearance: none;
border: 1px solid #2b394a;
padding: 0.7rem 0.85rem;
background: #fffaf1;
color: #243244;
font: inherit;
font-weight: 800;
cursor: pointer;
}
.forum-popup__button--quote {
border-color: #7a351a;
background: linear-gradient(180deg, #cb6a3f, #a64d28);
color: #fff7ef;
}
.forum-popup__button--copy[data-state="copied"] {
background: #2b394a;
color: #fff8ef;
}
`;
const popup = document.createElement('div');
popup.className = 'forum-popup';
popup.innerHTML = `
<div class="forum-popup__frame">
<div class="forum-popup__topline">
<span class="forum-popup__label">Forum actions</span>
<button type="button" class="forum-popup__close" aria-label="Close">
<svg viewBox="0 0 16 16" aria-hidden="true">
<path d="M3 3L13 13"></path>
<path d="M13 3L3 13"></path>
</svg>
</button>
</div>
<div class="forum-popup__actions">
<button type="button" class="forum-popup__button forum-popup__button--quote">Quote</button>
<button type="button" class="forum-popup__button forum-popup__button--copy">Copy</button>
</div>
</div>
`;
const closeButton = popup.querySelector('.forum-popup__close');
const quoteButton = popup.querySelector('.forum-popup__button--quote');
const copyButton = popup.querySelector('.forum-popup__button--copy');
closeButton.addEventListener('click', () => currentContext.close());
copyButton.addEventListener('click', () => currentContext.copy());
quoteButton.addEventListener('click', () => {
appendQuote(authorName, currentContext.text);
currentContext.close();
});
shadowRoot.append(style, popup);
return {
element: host,
update(nextContext) {
currentContext = nextContext;
copyButton.textContent = nextContext.copyLabel;
copyButton.dataset.state = nextContext.copied ? 'copied' : 'ready';
}
};
}
};
}
CleanSelection.attach(postBody, {
includeCompleteWords: true,
mergeSelections: true,
allowErase: true,
showTextPreview: false,
externalVirtualPadding: 17,
popupOffset: 22,
popupRenderer: createForumPopupRenderer(author)
});
</script>