| name | Library/Mr-xRed/FormattingToolbar |
|---|---|
| tags | meta/library |
| pageDecoration.prefix | ✏️ |
This library introduces a context-sensitive formatting toolbar that materialises only when you select text, providing quick access to functions such as Bold, Italic, Strikethrough, Blockquotes, etc. It manages to be quite helpful without cluttering your screen.
config.set("FormattingToolbar", {
enabled = true,
debug = false,
showBold = true,
showItalic = true,
showStrike = true,
showMark = true,
showSuper = true,
showSub = true,
showCode = true,
showQuote = true,
showBullet = true,
showNumber = true,
showTask = true,
showH1 = true,
showH2 = true,
showH3 = true,
showH4 = true,
showH5 = true,
showH6 = true,
-- example to add a formatting button for copyable library
customButtons = {
{
name = "Text: Copy Block", -- unique, becomes the command name
key = "Copy", -- tooltip text (optional, defaults to name)
start = "[copy]", -- starting marker delimiter
stop = "[/copy]", -- "end" is a Lua reserved word, hence "stop"
icon = [[<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>]],
show = true -- optional, defaults to true
},
}
})/* -- Theme tokens ------------------------------------------------------- */
html[data-theme="dark"] #sb-fmttb-wrap {
--fmttb-bg: oklch(0.14 0.01 270);
--fmttb-border: oklch(0.45 0 0);
--fmttb-text: oklch(0.88 0 0);
--fmttb-hover-bg: oklch(1 0 0 / 0.10);
--fmttb-active-bg: oklch(1 0 0 / 0.20);
--fmttb-on-bg: oklch(0.62 0.14 255 / 0.32);
--fmttb-on-dot: oklch(0.75 0.18 255);
--fmttb-sep: oklch(1 0 0 / 0.20);
--fmttb-drop-shadow: drop-shadow(0 8px 20px oklch(0 0 0 / 0.5));
}
html[data-theme="light"] #sb-fmttb-wrap {
--fmttb-bg: oklch(0.99 0 0);
--fmttb-border: oklch(0.8 0 0);
--fmttb-text: oklch(0.18 0 0);
--fmttb-hover-bg: oklch(0 0 0 / 0.06);
--fmttb-active-bg: oklch(0 0 0 / 0.13);
--fmttb-on-bg: oklch(0.55 0.12 255 / 0.12);
--fmttb-on-dot: oklch(0.45 0.18 255);
--fmttb-sep: oklch(0 0 0 / 0.20);
--fmttb-drop-shadow: drop-shadow(0 4px 12px oklch(0 0 0 / 0.15));
}
/* -- HyperOS / Android WebView compatibility ---------------------------- */
/* Suppresses the native Android selection menu (AI Visual Recognition, */
/* Secure Keyboard) so CodeMirror's own selection events reach the toolbar. */
/* Firefox for Android does not need this; it is a no-op on desktop. */
.cm-editor,
.cm-content {
-webkit-touch-callout: none; /* disables long-press callout (iOS/Android) */
-webkit-user-select: text; /* keep text selectable, but via CM, not OS */
}
/* -- Toolbar Container -------------------------------------------------- */
#sb-fmttb-wrap {
position: fixed;
z-index: 10000;
display: flex;
align-items: **center**er**;
gap: 2px;
padding: 4px;
border-radius: 8px;
border: 1px solid var(--fmttb-border);
background: var(--fmttb-bg);
backdrop-filter: blur(14px) saturate(1.6);
-webkit-backdrop-filter: blur(14px) saturate(1.6);
filter: var(--fmttb-drop-shadow);
user-select: none;
pointer-events: all;
touch-action: manipulation; /* reduces HyperOS gesture interference */
opacity: 0;
transform: translateY(6px) scale(0.96);
visibility: hidden;
transition:
opacity 0.12s ease,
transform 0.12s ease,
visibility 0s linear 0.12s;
will-change: transform, opacity;
}
#sb-fmttb-wrap.sb-fmttb-on {
opacity: 1;
transform: translateY(0) scale(1);
visibility: visible;
transition:
opacity 0.12s ease,
transform 0.12s ease,
visibility 0s linear 0s;
}
/* -- Read-only mode: suppress toolbar via CSS :has() -------------------- */
/* Overrides .sb-fmttb-on when the CM editor has contenteditable="false" */
body:has(.cm-content[contenteditable="false"]) #sb-fmttb-wrap {
opacity: 0 !important;
visibility: hidden !important;
pointer-events: none !important;
}
/* -- Mobile Overrides --------------------------------------------------- */
#sb-fmttb-wrap.sb-fmttb-mobile {
left: 0 !important;
right: 0 !important;
width: 100vw !important;
max-width: 100vw !important;
border-radius: 0 !important;
border-left: none !important;
border-right: none !important;
border-bottom: none !important;
padding: 8px 8px !important;
overflow-x: auto !important;
justify-content: flex-start !important;
-webkit-overflow-scrolling: touch;
scrollbar-width: none;
transform: none !important;
touch-action: pan-x; /* ← add this */
}
#sb-fmttb-wrap.sb-fmttb-mobile .sb-fmttb-btn {
flex-shrink: 0;
padding: 8px 8px;
}
#sb-fmttb-wrap.sb-fmttb-mobile .sb-fmttb-sep {
flex-shrink: 0;
}
#sb-fmttb-wrap.sb-fmttb-mobile::-webkit-scrollbar {
display: none;
}
#sb-fmttb-wrap.sb-fmttb-mobile .sb-fmttb-btn svg {width: 18fmttb-active::afterpx; height:18px;}
/* -- Buttons ------------------------------------------------------------ */
.sb-fmttb-btn {
position: relative;
background: transparent;
border: none;
color: var(--fmttb-text);
cursor: pointer;
padding: 8px 8px;
border-radius: 6px;
display: inline-flex;
align-items: center;
justify-content: center;
-webkit-tap-highlight-color: transparent;
transition: background 0.1s, transform 0.08s;
}
.sb-fmttb-btn:hover { background: var(--fmttb-hover-bg); }
.sb-fmttb-btn:active { background: var(--fmttb-active-bg); transform: scale(0.9); }
.sb-fmttb-btn svg { display: block; pointer-events: none;}
/* -- Active / "formatting is on" state ---------------------------------- */
.sb-fmttb-btn.sb-fmttb-active {
background: var(--fmttb-on-bg);
}
.sb-fmttb-btn.sb-fmttb-active::after {
content: '';
position: absolute;
bottom: 3px;
left: 50%;
translate: -50% 0;
width: 4px;
height: 4px;
border-radius: 50%;
background: var(--fmttb-on-dot);
}
.sb-fmttb-sep {
width: 1px;
height: 20px;
background: var(--fmttb-sep);
align-self: center;
margin: 0 2px;
flex-shrink: 0;
}
/* -- Tethered Arrow ----------------------------------------------------- */
#sb-fmttb-arrow {
position: absolute;
width: 0;
height: 0;
border-left: 8px solid transparent;
border-right: 8px solid transparent;
pointer-events: none;
}
#sb-fmttb-wrap.sb-arr-down #sb-fmttb-arrow {
top: 100%;
border-top: 8px solid var(--fmttb-border);
border-bottom: none;
margin-top: 0px;
}
#sb-fmttb-wrap.sb-arr-up #sb-fmttb-arrow {
bottom: 100%;
border-bottom: 8px solid var(--fmttb-border);
border-top: none;
margin-bottom: 0px;
}
/* Inner (bg-coloured) triangle — sits on top, leaving 1 px border on the two slanted edges */
#sb-fmttb-arrow::after {
content: '';
position: absolute;
width: 0;
height: 0;
border-left: 7px solid transparent;
border-right: 7px solid transparent;
}
#sb-fmttb-wrap.sb-arr-down #sb-fmttb-arrow::after {
border-top: 7px solid var(--fmttb-bg);
border-bottom: none;
top: -8px;
left: -7px;
}
#sb-fmttb-wrap.sb-arr-up #sb-fmttb-arrow::after {
border-bottom: 7px solid var(--fmttb-bg);
border-top: none;
top: 1px;
left: -7px;
}
-- priority: -1
config.define("FormattingToolbar", {
type = "object",
properties = {
enabled = { type = "boolean" },
debug = { type = "boolean" },
showBold = { type = "boolean" },
showItalic = { type = "boolean" },
showStrike = { type = "boolean" },
showMark = { type = "boolean" },
showSuper = { type = "boolean" },
showSub = { type = "boolean" },
showCode = { type = "boolean" },
showQuote = { type = "boolean" },
showBullet = { type = "boolean" },
showNumber = { type = "boolean" },
showTask = { type = "boolean" },
showH1 = { type = "boolean" },
showH2 = { type = "boolean" },
showH3 = { type = "boolean" },
showH4 = { type = "boolean" },
showH5 = { type = "boolean" },
showH6 = { type = "boolean" },
customButtons = { type = "array" }
}
})
-- ============================================================
-- Marker / command registry
-- Order matters: longer markers must appear before shorter ones
-- so that "~~" is tested before "~", "**" before "*", etc.
-- ============================================================
local FMTTB_ALL_MARKERS = { "**", "~~", "==", "_", "^", "~", "`" }
-- Maps command name → marker string (only for inline-wrap commands)
local FMTTB_CMD_TO_MARKER = {
["Text: Bold"] = "**",
["Text: Italic"] = "_",
["Text: Strikethrough"] = "~~",
["Text: Marker"] = "==",
["Text: Toggle Superscript"] = "^",
["Text: Toggle Subscript"] = "~",
["Text: Toggle Code"] = "`",
}
-- Ordered list used for active-state detection
local FMTTB_MARKER_CMD = {
{ marker = "**", cmd = "Text: Bold" },
{ marker = "_", cmd = "Text: Italic" },
{ marker = "~~", cmd = "Text: Strikethrough" },
{ marker = "==", cmd = "Text: Marker" },
{ marker = "^", cmd = "Text: Toggle Superscript" },
{ marker = "~", cmd = "Text: Toggle Subscript" },
{ marker = "`", cmd = "Text: Toggle Code" },
}
local fmttb_showing = false
-- ============================================================
-- Utility helpers
-- ============================================================
function fmttb_normalize_sel(raw)
if not raw then return nil end
local f = math.min(raw.from, raw.to)
local t = math.max(raw.from, raw.to)
if f == t then return nil end
return { from = f, to = t }
end
function fmttb_split_lines(text)
local lines = {}
local remaining = text
while true do
local nl = remaining:find("\n")
if nl then
table.insert(lines, remaining:sub(1, nl - 1))
remaining = remaining:sub(nl + 1)
else
table.insert(lines, remaining)
break
end
end
return lines
end
function fmttb_get_line_range(full, sel)
if not sel or sel.from >= sel.to then return nil, nil, nil end
local doc_len = #full
local from_lua = sel.from + 1
local line_from_lua = from_lua
while line_from_lua > 1 do
local prev = full:sub(line_from_lua - 1, line_from_lua - 1)
if prev == "\n" then break end
line_from_lua = line_from_lua - 1
end
local line_to_lua = sel.to
if line_to_lua > 0 and line_to_lua <= doc_len then
if full:sub(line_to_lua, line_to_lua) == "\n" then
line_to_lua = line_to_lua - 1
end
end
while line_to_lua < doc_len do
local next_char = full:sub(line_to_lua + 1, line_to_lua + 1)
if next_char == "\n" then break end
line_to_lua = line_to_lua + 1
end
local line_from_cm = line_from_lua - 1
local line_to_cm = line_to_lua
if line_from_cm > line_to_cm then return nil, nil, nil end
return line_from_cm, line_to_cm, full:sub(line_from_lua, line_to_lua)
end
function fmttb_get_len(text)
if utf8 and utf8.len then
local ok, len = pcall(utf8.len, text)
if ok and len then return len end
end
return #text
end
-- Character threshold above which a single-line selection is treated
-- as a code *block* rather than inline code (≈ 10 average words)
local FMTTB_CODE_BLOCK_THRESHOLD = 60
-- Returns true when the trimmed text is already a fenced code block,
-- i.e. it starts with ``` (+ optional lang) and ends with ```.
function fmttb_is_codeblock(text)
local trimmed = text:match("^%s*(.-)%s*$") or ""
return trimmed:match("^```[^\n]*\n") ~= nil
and trimmed:match("\n```%s*$") ~= nil
end
-- ============================================================
-- Marker scanning — outward (beyond the selection edges)
-- ============================================================
function fmttb_scan_outward(full, cm_from, cm_to)
local left_markers = {}
local right_markers = {}
local pos = cm_from
while pos > 0 do
local found = false
for _, m in ipairs(FMTTB_ALL_MARKERS) do
local mlen = #m
if pos >= mlen then
local chunk = full:sub(pos - mlen + 1, pos)
if chunk == m then
table.insert(left_markers, m)
pos = pos - mlen
found = true
break
end
end
end
if not found then break end
end
local pos2 = cm_to + 1
local doc_len = #full
while pos2 <= doc_len do
local found = false
for _, m in ipairs(FMTTB_ALL_MARKERS) do
local mlen = #m
if pos2 + mlen - 1 <= doc_len then
local chunk = full:sub(pos2, pos2 + mlen - 1)
if chunk == m then
table.insert(right_markers, m)
pos2 = pos2 + mlen
found = true
break
end
end
end
if not found then break end
end
return left_markers, right_markers
end
-- ============================================================
-- Marker scanning — inward (from the edges of the selected text)
-- ============================================================
function fmttb_scan_inward(selected_text)
local left_markers = {}
local right_markers = {}
local text_len = #selected_text
local pos = 1
while pos <= text_len do
local found = false
for _, m in ipairs(FMTTB_ALL_MARKERS) do
local mlen = #m
if pos + mlen - 1 <= text_len then
local chunk = selected_text:sub(pos, pos + mlen - 1)
if chunk == m then
table.insert(left_markers, m)
pos = pos + mlen
found = true
break
end
end
end
if not found then break end
end
local pos2 = text_len
while pos2 > 0 do
local found = false
for _, m in ipairs(FMTTB_ALL_MARKERS) do
local mlen = #m
if pos2 - mlen + 1 >= 1 then
local chunk = selected_text:sub(pos2 - mlen + 1, pos2)
if chunk == m then
table.insert(right_markers, m)
pos2 = pos2 - mlen
found = true
break
end
end
end
if not found then break end
end
return left_markers, right_markers
end
-- ============================================================
-- Active-marker detection
-- ============================================================
function fmttb_detect_active_markers(full, sel)
local selected_text = full:sub(sel.from + 1, sel.to)
local lo, ro = fmttb_scan_outward(full, sel.from, sel.to)
local li, ri = fmttb_scan_inward(selected_text)
local function has(list, m)
for _, v in ipairs(list) do
if v == m then return true end
end
return false
end
local active_cmds = {}
for _, item in ipairs(FMTTB_MARKER_CMD) do
local m = item.marker
local left_has = has(lo, m) or has(li, m)
local right_has = has(ro, m) or has(ri, m)
if left_has and right_has then
table.insert(active_cmds, item.cmd)
end
end
-- Also light up the Code button when the selection is a fenced code block
if fmttb_is_codeblock(selected_text) then
local already = false
for _, c in ipairs(active_cmds) do
if c == "Text: Toggle Code" then already = true; break end
end
if not already then
table.insert(active_cmds, "Text: Toggle Code")
end
end
return table.concat(active_cmds, ",")
end
-- ============================================================
-- Smart selection expansion
-- ============================================================
function fmttb_expand_for_marker(marker, sel, full)
local m_len = #marker
local selected_text = full:sub(sel.from + 1, sel.to)
if #selected_text >= 2 * m_len
and selected_text:sub(1, m_len) == marker
and selected_text:sub(-m_len) == marker then
return
end
local left_expand = 0
local pos = sel.from
local found_left = false
while pos > 0 do
local found = false
for _, m in ipairs(FMTTB_ALL_MARKERS) do
local mlen = #m
if pos >= mlen and full:sub(pos - mlen + 1, pos) == m then
left_expand = left_expand + mlen
pos = pos - mlen
if m == marker then found_left = true end
found = true
break
end
end
if found_left or not found then break end
end
local right_expand = 0
local pos2 = sel.to + 1
local doc_len = #full
local found_right = false
while pos2 <= doc_len do
local found = false
for _, m in ipairs(FMTTB_ALL_MARKERS) do
local mlen = #m
if pos2 + mlen - 1 <= doc_len and full:sub(pos2, pos2 + mlen - 1) == m then
right_expand = right_expand + mlen
pos2 = pos2 + mlen
if m == marker then found_right = true end
found = true
break
end
end
if found_right or not found then break end
end
if found_left and found_right then
editor.setSelection(sel.from - left_expand, sel.to + right_expand)
end
end
-- ============================================================
-- Custom toggle commands
-- ============================================================
function fmttb_toggle_inline(marker)
local ok_sel, raw_sel = pcall(editor.getSelection)
local sel = fmttb_normalize_sel(raw_sel)
local ok_txt, full = pcall(editor.getText)
if not (sel and ok_txt) then return end
local selected_text = full:sub(sel.from + 1, sel.to)
local m_len = #marker
local start_match = selected_text:sub(1, m_len) == marker
local end_match = selected_text:sub(-m_len) == marker
if start_match and end_match then
local inner = selected_text:sub(m_len + 1, -m_len - 1)
editor.replaceRange(sel.from, sel.to, inner)
editor.setSelection(sel.from, sel.from + fmttb_get_len(inner))
else
local new_text = marker .. selected_text .. marker
editor.replaceRange(sel.from, sel.to, new_text)
editor.setSelection(sel.from, sel.from + fmttb_get_len(new_text))
end
end
-- ============================================================
-- Generic wrap/unwrap toggle (supports asymmetric delimiters,
-- e.g. start="[copy]" end="[/copy]"). fmttb_toggle_inline (below)
-- is just this called with startM == endM.
-- ============================================================
function fmttb_toggle_wrap(startM, endM)
local ok_sel, raw_sel = pcall(editor.getSelection)
local sel = fmttb_normalize_sel(raw_sel)
local ok_txt, full = pcall(editor.getText)
if not (sel and ok_txt) then return end
local selected_text = full:sub(sel.from + 1, sel.to)
local sM, eM = #startM, #endM
-- Case 1: the wrapper is INSIDE the selection, e.g. selection is "[copy]text[/copy]"
if #selected_text >= sM + eM
and selected_text:sub(1, sM) == startM
and selected_text:sub(-eM) == endM then
local inner = selected_text:sub(sM + 1, -eM - 1)
editor.replaceRange(sel.from, sel.to, inner)
editor.setSelection(sel.from, sel.from + fmttb_get_len(inner))
return
end
-- Case 2: the wrapper sits immediately OUTSIDE the selection
-- (selection is just "text", cursor already between the delimiters)
local before = sel.from - sM >= 0 and full:sub(sel.from - sM + 1, sel.from) or ""
local after = full:sub(sel.to + 1, sel.to + eM)
if before == startM and after == endM then
local new_from = sel.from - sM
local new_to = sel.to + eM
editor.replaceRange(new_from, new_to, selected_text)
editor.setSelection(new_from, new_from + fmttb_get_len(selected_text))
return
end
-- Case 3: not wrapped yet — wrap it
local new_text = startM .. selected_text .. endM
editor.replaceRange(sel.from, sel.to, new_text)
editor.setSelection(sel.from, sel.from + fmttb_get_len(new_text))
end
-- Same active-state logic as fmttb_toggle_wrap, but read-only (for button highlighting)
function fmttb_detect_custom_active(full, sel, startM, endM)
local selected_text = full:sub(sel.from + 1, sel.to)
local sM, eM = #startM, #endM
if #selected_text >= sM + eM
and selected_text:sub(1, sM) == startM
and selected_text:sub(-eM) == endM then
return true
end
local before = sel.from - sM >= 0 and full:sub(sel.from - sM + 1, sel.from) or ""
local after = full:sub(sel.to + 1, sel.to + eM)
if before == startM and after == endM then
return true
end
return false
end
command.define {
name = "Text: Toggle Superscript",
run = function() fmttb_toggle_inline("^") end
}
command.define {
name = "Text: Toggle Subscript",
run = function() fmttb_toggle_inline("~") end
}
command.define {
name = "Text: Toggle Code",
run = function()
local ok_sel, raw_sel = pcall(editor.getSelection)
local sel = fmttb_normalize_sel(raw_sel)
local ok_txt, full = pcall(editor.getText)
if not (sel and ok_txt) then return end
local selected_text = full:sub(sel.from + 1, sel.to)
local is_multiline = selected_text:find("\n") ~= nil
local is_long = fmttb_get_len(selected_text) >= FMTTB_CODE_BLOCK_THRESHOLD
if is_multiline or is_long then
-- ── Fenced code-block toggle ───────────────────────────────
-- Expand to complete lines so the fences always sit on clean boundaries
local lf, lt, block = fmttb_get_line_range(full, sel)
if not lf then return end
if fmttb_is_codeblock(block) then
-- Already a block: strip the opening and closing fence lines
local inner = block
inner = inner:gsub("^%s*```[^\n]*\n", "")
inner = inner:gsub("\n```%s*$", "")
editor.replaceRange(lf, lt, inner)
editor.setSelection(lf, lf + fmttb_get_len(inner))
else
-- Wrap the full lines in a fenced code block
local new_text = "```\n" .. block .. "\n```"
editor.replaceRange(lf, lt, new_text)
editor.setSelection(lf, lf + fmttb_get_len(new_text))
end
else
-- ── Inline backtick toggle (short / single-line) ───────────
fmttb_toggle_inline("`")
end
end
}
command.define {
name = "Text: Toggle Bullet List",
run = function()
local ok_sel, raw_sel = pcall(editor.getSelection)
local sel = fmttb_normalize_sel(raw_sel)
local ok_txt, full = pcall(editor.getText)
if not (sel and ok_txt) then return end
local lf, lt, block = fmttb_get_line_range(full, sel)
if not lf then return end
local lines = fmttb_split_lines(block)
local all_are_bullets = true
for _, l in ipairs(lines) do
if l ~= "" then
local is_bullet = l:match("^%- ")
if not is_bullet then
all_are_bullets = false
break
end
end
end
local nl = {}
for _, l in ipairs(lines) do
local s = l
if all_are_bullets then
s = (l:gsub("^%- ", ""))
else
if l ~= "" then
s = (l:gsub("^%- ", ""))
s = "- " .. s
end
end
table.insert(nl, s)
end
local new_block = table.concat(nl, "\n")
editor.replaceRange(lf, lt, new_block)
editor.setSelection(lf, lf + fmttb_get_len(new_block))
end
}
command.define {
name = "Text: Toggle Numbered List",
run = function()
local ok_sel, raw_sel = pcall(editor.getSelection)
local sel = fmttb_normalize_sel(raw_sel)
local ok_txt, full = pcall(editor.getText)
if not (sel and ok_txt) then return end
local lf, lt, block = fmttb_get_line_range(full, sel)
if not lf then return end
local lines = fmttb_split_lines(block)
local all_are_numbered = true
for _, l in ipairs(lines) do
if l ~= "" then
local is_numbered = l:match("^%d+%. ")
if not is_numbered then
all_are_numbered = false
break
end
end
end
local nl = {}
local count = 1
for _, l in ipairs(lines) do
local s = l
if all_are_numbered then
s = (l:gsub("^%d+%. ", ""))
else
if l ~= "" then
s = (l:gsub("^%d+%. ", ""))
s = (s:gsub("^%- ", ""))
s = (s:gsub("^%- %[.%] ", ""))
s = count .. ". " .. s
count = count + 1
end
end
table.insert(nl, s)
end
local new_block = table.concat(nl, "\n")
editor.replaceRange(lf, lt, new_block)
editor.setSelection(lf, lf + fmttb_get_len(new_block))
end
}
command.define {
name = "Text: Toggle Task",
run = function()
local ok_sel, raw_sel = pcall(editor.getSelection)
local sel = fmttb_normalize_sel(raw_sel)
local ok_txt, full = pcall(editor.getText)
if not (sel and ok_txt) then return end
local lf, lt, block = fmttb_get_line_range(full, sel)
if not lf then return end
local lines = fmttb_split_lines(block)
local all_are_tasks = true
for _, l in ipairs(lines) do
if l ~= "" then
local is_task = l:match("^%- %[.%] ")
if not is_task then
all_are_tasks = false
break
end
end
end
local nl = {}
for _, l in ipairs(lines) do
local s = l
if all_are_tasks then
s = (l:gsub("^%- %[.%] ", ""))
else
if l ~= "" then
s = (l:gsub("^%- %[.%] ", ""))
s = (s:gsub("^%- ", ""))
s = (s:gsub("^%d+%. ", ""))
s = "- [ ] " .. s
end
end
table.insert(nl, s)
end
local new_block = table.concat(nl, "\n")
editor.replaceRange(lf, lt, new_block)
editor.setSelection(lf, lf + fmttb_get_len(new_block))
end
}
command.define {
name = "Text: Toggle Quote",
run = function()
local ok_sel, raw_sel = pcall(editor.getSelection)
local sel = fmttb_normalize_sel(raw_sel)
local ok_txt, full = pcall(editor.getText)
if not (sel and ok_txt) then return end
local lf, lt, block = fmttb_get_line_range(full, sel)
if not lf then return end
local lines = fmttb_split_lines(block)
local all_are_quotes = true
for _, l in ipairs(lines) do
if l ~= "" then
local is_quote = l:match("^%> ")
if not is_quote then
all_are_quotes = false
break
end
end
end
local nl = {}
for _, l in ipairs(lines) do
local s = l
if all_are_quotes then
s = (l:gsub("^%> ", ""))
else
if l ~= "" then
s = (l:gsub("^%> ", ""))
s = "> " .. s
end
end
table.insert(nl, s)
end
local new_block = table.concat(nl, "\n")
editor.replaceRange(lf, lt, new_block)
editor.setSelection(lf, lf + fmttb_get_len(new_block))
end
}
-- ============================================================
-- Heading toggle helper and commands (H1–H6)
-- ============================================================
function fmttb_toggle_heading(level)
local ok_sel, raw_sel = pcall(editor.getSelection)
local sel = fmttb_normalize_sel(raw_sel)
local ok_txt, full = pcall(editor.getText)
if not (sel and ok_txt) then return end
local lf, lt, block = fmttb_get_line_range(full, sel)
if not lf then return end
-- Build prefix for this heading level, e.g. "## " for level 2
local prefix = string.rep("#", level) .. " "
-- Detection pattern: exactly N hashes followed by a space
-- (^# " won't match "## " because char 2 would be # not space)
local detect_pat = "^" .. string.rep("#", level) .. " "
-- Strip pattern: any run of hashes + space at line start
local strip_pat = "^#+ "
local lines = fmttb_split_lines(block)
local all_are_h = true
for _, l in ipairs(lines) do
if l ~= "" then
if not l:match(detect_pat) then
all_are_h = false
break
end
end
end
local nl = {}
for _, l in ipairs(lines) do
local s = l
if all_are_h then
s = l:gsub(strip_pat, "")
else
if l ~= "" then
s = l:gsub(strip_pat, "")
s = prefix .. s
end
end
table.insert(nl, s)
end
local new_block = table.concat(nl, "\n")
editor.replaceRange(lf, lt, new_block)
editor.setSelection(lf, lf + fmttb_get_len(new_block))
end
for _level = 1, 6 do
local lvl = _level
command.define {
name = "Text: Toggle Heading " .. lvl,
run = function() fmttb_toggle_heading(lvl) end
}
end
-- ============================================================
-- Custom (config-defined) commands
-- ============================================================
-- Add new toolbar buttons purely via config.set, no code changes:
--
-- config.set("FormattingToolbar", {
-- customButtons = {
-- {
-- name = "Text: Copy Block", -- must be unique, becomes the command name
-- key = "Copy", -- tooltip text (optional, defaults to name)
-- start = "[copy]",
-- stop = "[/copy]", -- ("end" is a reserved word in Lua, so use "stop")
-- icon = [[<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>]],
-- show = true -- optional, defaults to true
-- }
-- }
-- })
--
-- Symmetric delimiters work too: just set start and stop to the same string.
local _fmttb_boot_cfg = config.get("FormattingToolbar") or {}
if _fmttb_boot_cfg.customButtons then
for _, item in ipairs(_fmttb_boot_cfg.customButtons) do
local startM, endM = item.start, item.stop
command.define {
name = item.name,
run = function() fmttb_toggle_wrap(startM, endM) end
}
end
end
-- ============================================================
-- DOM Construction
-- ============================================================
function buildFormattingToolbar()
if js.window.document.getElementById("sb-fmttb-wrap") then return end
local cfg = config.get("FormattingToolbar") or {}
local icons = {
bold = [[<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M6 12h9a4 4 0 0 1 0 8H6a1 1 0 0 1-1-1V5a1 1 0 0 1 1-1h7a4 4 0 0 1 0 8"></path></svg>]],
italic = [[<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><line x1="19" y1="4" x2="10" y2="4"></line><line x1="14" y1="20" x2="5" y2="20"></line><line x1="15" y1="4" x2="9" y2="20"></line></svg>]],
strike = [[<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M16 4H9a3 3 0 0 0-2.83 4"></path><path d="M14 12a4 4 0 0 1 0 8H6"></path><line x1="4" y1="12" x2="20" y2="12"></line></svg>]],
marker = [[<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="m9 11-6 6v3h9l3-3"></path><path d="m22 12-4.6 4.6a2 2 0 0 1-2.8 0l-5.2-5.2a2 2 0 0 1 0-2.8L14 4"></path></svg>]],
super = [[<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m4 19 8-8"/><path d="m12 19-8-8"/><path d="M20 12h-4c0-1.5.442-2 1.5-2.5S20 8.334 20 7.002c0-.472-.17-.93-.484-1.29a2.105 2.105 0 0 0-2.617-.436c-.42.239-.738.614-.899 1.06"/></svg>]],
sub = [[<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m4 5 8 8"/><path d="m12 5-8 8"/><path d="M20 19h-4c0-1.5.44-2 1.5-2.5S20 15.33 20 14c0-.47-.17-.93-.48-1.29a2.11 2.11 0 0 0-2.62-.44c-.42.24-.74.62-.9 1.07"/></svg>]],
code = [[<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="16 18 22 12 16 6"></polyline><polyline points="8 6 2 12 8 18"></polyline></svg>]],
quote = [[<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M3 21c3 0 7-1 7-8V5c0-1.25-.756-2.017-2-2H4c-1.25 0-2 .75-2 1.972V11c0 1.25.75 2 2 2 1 0 1 0 1 1 0 2.5 0 5-2 5"></path><path d="M11 21c3 0 7-1 7-8V5c0-1.25-.756-2.017-2-2h-4c-1.25 0-2 .75-2 1.972V11c0 1.25.75 2 2 2 1 0 1 0 1 1 0 2.5 0 5-2 5"></path></svg>]],
bullet = [[<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><line x1="8" y1="6" x2="21" y2="6"></line><line x1="8" y1="12" x2="21" y2="12"></line><line x1="8" y1="18" x2="21" y2="18"></line><line x1="3" y1="6" x2="3.01" y2="6"></line><line x1="3" y1="12" x2="3.01" y2="12"></line><line x1="3" y1="18" x2="3.01" y2="18"></line></svg>]],
number = [[<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><line x1="10" y1="6" x2="21" y2="6"></line><line x1="10" y1="12" x2="21" y2="12"></line><line x1="10" y1="18" x2="21" y2="18"></line><path d="M4 6h1v4"></path><path d="M4 10h2"></path><path d="M6 18H4c0-1 2-2 2-3s-1-1.5-2-1"></path></svg>]],
task = [[<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><rect width="18" height="18" x="3" y="3" rx="2"></rect><path d="m9 11 3 3L22 4"></path></svg>]],
h1 = [[<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="currentColor" stroke="none"><text x="1" y="18" font-family="sans-serif" font-size="16" font-weight="700">H1</text></svg>]],
h2 = [[<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="currentColor" stroke="none"><text x="1" y="18" font-family="sans-serif" font-size="16" font-weight="700">H2</text></svg>]],
h3 = [[<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="currentColor" stroke="none"><text x="1" y="18" font-family="sans-serif" font-size="16" font-weight="700">H3</text></svg>]],
h4 = [[<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="currentColor" stroke="none"><text x="1" y="18" font-family="sans-serif" font-size="16" font-weight="700">H4</text></svg>]],
h5 = [[<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="currentColor" stroke="none"><text x="1" y="18" font-family="sans-serif" font-size="16" font-weight="700">H5</text></svg>]],
h6 = [[<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="currentColor" stroke="none"><text x="1" y="18" font-family="sans-serif" font-size="16" font-weight="700">H6</text></svg>]]
}
local buttons = {
{ i=icons.bold, t="Bold", c="Text: Bold" },
{ i=icons.italic, t="Italic", c="Text: Italic" },
{ i=icons.strike, t="Strike", c="Text: Strikethrough" },
{ i=icons.marker, t="Mark", c="Text: Marker" },
{ i=icons.super, t="Super", c="Text: Toggle Superscript" },
{ i=icons.sub, t="Sub", c="Text: Toggle Subscript" },
{ i=icons.code, t="Code", c="Text: Toggle Code" },
{ divider=true },
{ i=icons.quote, t="Quote", c="Text: Toggle Quote" },
{ i=icons.bullet, t="Bullet", c="Text: Toggle Bullet List" },
{ i=icons.number, t="Number", c="Text: Toggle Numbered List" },
{ i=icons.task, t="Task", c="Text: Toggle Task" },
{ divider=true },
{ i=icons.h1, t="H1", c="Text: Toggle Heading 1" },
{ i=icons.h2, t="H2", c="Text: Toggle Heading 2" },
{ i=icons.h3, t="H3", c="Text: Toggle Heading 3" },
{ i=icons.h4, t="H4", c="Text: Toggle Heading 4" },
{ i=icons.h5, t="H5", c="Text: Toggle Heading 5" },
{ i=icons.h6, t="H6", c="Text: Toggle Heading 6" },
}
if cfg.customButtons and #cfg.customButtons > 0 then
table.insert(buttons, { divider = true })
for _, item in ipairs(cfg.customButtons) do
if item.show ~= false then
table.insert(buttons, { i = item.icon, t = item.key or item.name, c = item.name })
end
end
end
local html = ""
local last_was_divider = true
local pending_divider = false
for _, item in ipairs(buttons) do
if item.divider then
pending_divider = true
else
local key = "show" .. item.t
if cfg[key] ~= false then
if pending_divider and not last_was_divider then
html = html .. '<div class="sb-fmttb-sep"></div>'
end
html = html .. '<button class="sb-fmttb-btn" title="' .. item.t .. '" data-cmd="' .. item.c .. '">' .. item.i .. '</button>'
last_was_divider = false
pending_divider = false
end
end
end
html = html .. '<div id="sb-fmttb-arrow"></div>'
local toolbar = js.window.document.createElement("div")
toolbar.id = "sb-fmttb-wrap"
toolbar.innerHTML = html
js.window.document.body.appendChild(toolbar)
local scriptEl = js.window.document.createElement("script")
scriptEl.id = "sb-fmttb-script"
scriptEl.innerHTML = [[
// Permanent trampolines — installed exactly once per page lifetime.
// Lua just replaces the _fn slots; no Lua-side addEventListener needed.
if (!window._sb_fmttb_tram) {
window._sb_fmttb_tram = true;
window.addEventListener('fmttb-poll', function () {
if (typeof window._sb_fmttb_lua_poll === 'function') window._sb_fmttb_lua_poll();
});
window.addEventListener('fmttb-cmd', function (e) {
if (typeof window._sb_fmttb_lua_cmd === 'function') window._sb_fmttb_lua_cmd(e);
});
}
(function () {
var MARGIN = 12, GAP = 8, TOP_G = 64;
var isMobile = /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent) || window.innerWidth < 768;
var positionLocked = false;
var lockedLeft = 0;
function getRect() {
var nSel = window.getSelection();
if (nSel && nSel.rangeCount > 0 && !nSel.isCollapsed) {
var r = nSel.getRangeAt(0).getBoundingClientRect();
if (r && r.height > 0) return r;
}
var bgs = document.querySelectorAll(".cm-selectionBackground");
if (bgs.length > 0) {
var t = 9999, b = -9999, l = 9999, r = -9999;
bgs.forEach(function (el) {
var rect = el.getBoundingClientRect();
if (rect.height > 0) {
t = Math.min(t, rect.top); b = Math.max(b, rect.bottom);
l = Math.min(l, rect.left); r = Math.max(r, rect.right);
}
});
return t < 9999 ? { top: t, bottom: b, left: l, right: r, width: r - l, height: b - t } : null;
}
return null;
}
function updateActiveButtons(tb) {
var activeStr = tb.getAttribute("data-active") || "";
tb.querySelectorAll(".sb-fmttb-btn").forEach(function (btn) {
var cmd = btn.getAttribute("data-cmd");
var isActive = cmd && activeStr.split(",").indexOf(cmd) !== -1;
btn.classList.toggle("sb-fmttb-active", isActive);
});
}
function update() {
var tb = document.getElementById("sb-fmttb-wrap");
var ar = document.getElementById("sb-fmttb-arrow");
if (!tb || tb.getAttribute("data-on") !== "1") {
positionLocked = false;
return;
}
updateActiveButtons(tb);
var r = getRect();
if (!r) return;
var tw = tb.offsetWidth, th = tb.offsetHeight;
if (isMobile) {
var vv = window.visualViewport;
var vh = vv ? vv.height : window.innerHeight;
var vt = vv ? vv.offsetTop : 0;
tb.style.top = Math.round(vt + vh - th) + "px";
tb.style.left = "0px";
tb.className = "sb-fmttb-mobile sb-fmttb-on";
if (ar) ar.style.display = "none";
return;
}
// --- VERTICAL CALCULATION (Always active) ---
var top = r.top - th - GAP;
var below = top < (TOP_G + MARGIN);
if (below) top = r.bottom + GAP;
tb.style.top = Math.round(top) + "px";
tb.className = below ? "sb-arr-up sb-fmttb-on" : "sb-arr-down sb-fmttb-on";
// --- HORIZONTAL CALCULATION ---
if (!positionLocked) {
var left = r.left + r.width / 2 - tw / 2;
left = Math.max(MARGIN, Math.min(left, window.innerWidth - tw - MARGIN));
tb.style.left = Math.round(left) + "px";
lockedLeft = left;
}
// --- ARROW CALCULATION (Always active) ---
if (ar) {
ar.style.display = "block";
var ax = Math.round(r.left + r.width / 2 - (lockedLeft || 0) - 7);
ar.style.left = Math.max(8, Math.min(ax, tw - 22)) + "px";
}
}
setInterval(function () {
var tb = document.getElementById("sb-fmttb-wrap");
if (!tb) return;
if (tb.getAttribute("data-on") === "1") update();
else {
tb.classList.remove("sb-fmttb-on");
positionLocked = false;
}
}, 100);
function poll() { window.dispatchEvent(new CustomEvent("fmttb-poll")); }
// Interactions that reset the lock
["mousedown", "keydown"].forEach(function(ev) {
document.addEventListener(ev, function(e) {
if (!e.target.closest("#sb-fmttb-wrap")) positionLocked = false;
}, { passive: true });
});
// Scrolling also resets the lock to allow full repositioning
var scroller = document.querySelector(".cm-scroller") || document;
scroller.addEventListener("scroll", function() {
positionLocked = false;
poll();
}, { passive: true });
["selectionchange", "mouseup", "touchend", "keyup"].forEach(function (ev) {
document.addEventListener(ev, poll, { passive: true });
});
if (window.visualViewport) {
window.visualViewport.addEventListener("resize", update);
window.visualViewport.addEventListener("scroll", update);
}
var lastTouchCmdTime = 0, touchStartX = 0, touchStartY = 0;
document.addEventListener("touchstart", function (e) {
var b = e.target.closest("#sb-fmttb-wrap [data-cmd]");
if (b) { touchStartX = e.touches[0].clientX; touchStartY = e.touches[0].clientY; }
}, { passive: true });
document.addEventListener("touchend", function (e) {
var b = e.target.closest("#sb-fmttb-wrap [data-cmd]");
if (!b) return;
e.preventDefault(); e.stopPropagation();
var dx = e.changedTouches[0].clientX - touchStartX, dy = e.changedTouches[0].clientY - touchStartY;
if (Math.sqrt(dx * dx + dy * dy) > 8) return;
lastTouchCmdTime = Date.now();
positionLocked = true; // Lock horizontal only
window.dispatchEvent(new CustomEvent("fmttb-cmd", { detail: { command: b.getAttribute("data-cmd") } }));
}, { passive: false });
document.addEventListener("mousedown", function (e) {
var b = e.target.closest("#sb-fmttb-wrap [data-cmd]");
if (!b) return;
e.preventDefault(); e.stopPropagation();
if (Date.now() - lastTouchCmdTime < 500) return;
positionLocked = true; // Lock horizontal only
window.dispatchEvent(new CustomEvent("fmttb-cmd", { detail: { command: b.getAttribute("data-cmd") } }));
});
})();
]]
js.window.document.body.appendChild(scriptEl)
end
function destroyFormattingToolbar()
for _, id in ipairs({ "sb-fmttb-wrap", "sb-fmttb-script" }) do
local el = js.window.document.getElementById(id)
if el then el.remove() end
end
-- Clear the Lua slots so the trampolines become no-ops
js.window._sb_fmttb_lua_poll = nil
js.window._sb_fmttb_lua_cmd = nil
end
-- ============================================================
-- Poll handler
-- ============================================================
-- Assigned to a window slot; the JS trampoline calls it.
-- Re-assigning on System: Reload simply replaces the slot — no stacking.
js.window._sb_fmttb_lua_poll = function()
local toolbar = js.window.document.getElementById("sb-fmttb-wrap")
if not toolbar then return end
local ok_sel, raw_sel = pcall(editor.getSelection)
local sel = fmttb_normalize_sel(raw_sel)
local has_sel = ok_sel and sel ~= nil
toolbar.setAttribute("data-on", has_sel and "1" or "0")
if has_sel then
local ok_txt, full = pcall(editor.getText)
if ok_txt then
local active = fmttb_detect_active_markers(full, sel)
local cfg = config.get("FormattingToolbar") or {}
if cfg.customButtons then
for _, item in ipairs(cfg.customButtons) do
if fmttb_detect_custom_active(full, sel, item.start, item.stop) then
active = (active == "" and item.name) or (active .. "," .. item.name)
end
end
end
toolbar.setAttribute("data-active", active)
end
else
toolbar.setAttribute("data-active", "")
end
end
-- ============================================================
-- Command handler
-- ============================================================
js.window._sb_fmttb_lua_cmd = function(e)
local cmd = e.detail.command
local marker = FMTTB_CMD_TO_MARKER[cmd]
if marker then
local ok_sel, raw_sel = pcall(editor.getSelection)
local sel = fmttb_normalize_sel(raw_sel)
local ok_txt, full = pcall(editor.getText)
if sel and ok_txt then
fmttb_expand_for_marker(marker, sel, full)
end
end
editor.invokeCommand(cmd)
end
-- ============================================================
-- Toggle command
-- ============================================================
command.define {
name = "Text: Formatting Toolbar",
run = function()
if js.window.document.getElementById("sb-fmttb-wrap") then
destroyFormattingToolbar()
clientStore.set("fmttb_enabled", "false")
else
buildFormattingToolbar()
clientStore.set("fmttb_enabled", "true")
end
end
}
event.listen { name = "editor:pageLoaded", run = function()
local cfg = config.get("FormattingToolbar") or {}
if cfg.enabled ~= false and clientStore.get("fmttb_enabled") ~= "false" then
buildFormattingToolbar()
end
end }