Hi all,
I'm sharing some (vibe coded, sorry) scripts for touch-screen devices that can reveal or hide control buttons for widgets.
0. Background
My index page is built of queries for tasks. On mobile, each query renders to a widget whose top right corner is blocked by the control buttons:
As we can see from this picture, it can be a bit awkward. Of course I can tilt my phone to landscape view, but I don't want to. One can, of course, hide all control buttons for read-only mode, but I also wanted a solution for editing mode. There is no particular reason why these control buttons should be coupled to read-only mode; I'd like them gone by default but easily available.
...Which is what the "hover" behavior on desktop achieves.
We can define "one trivial tap" as a "hover" on mobile, and that is indeed what the following scripts implement.
1. What it does
- Initially the top-right control buttons (refresh, copy, bake, edit) are hidden.
- Tapping at a trivial place (i.e. not a URL, wikilink, todo box, etc) shows the control buttons.
- Tapping again at a trivial place hides them. This means an odd number of trivial taps show the buttons, while an even number of trivial taps hide them.
Another reasonable choice is to disable control buttons upon a tap outside a widget, but that raises questions: are you trying to edit something outside or are you just disabling control buttons inside? If the query returns a long widget, will you be patient enough to scroll all the way? With these considerations, I decided to go with "tap again to hide".
The non-trivial parts inside a widget are still accessible. Wikilinks and external URLs will be followed upon tapping, ticking a todo box is still possible directly from a query widget.
@media (hover: none) and (pointer: coarse) {
#sb-root #sb-main #sb-editor
.cm-content:not([contenteditable="false"]):not([aria-readonly="true"])
.button-bar {
display: none !important;
}
#sb-root #sb-main #sb-editor
.cm-content:not([contenteditable="false"]):not([aria-readonly="true"])
.button-bar[data-mobile-controls-open] {
display: block !important;
opacity: 1 !important;
visibility: visible !important;
pointer-events: auto !important;
}
}
local win, doc = js.window, js.window.document
local mobile = win.matchMedia("(hover: none) and (pointer: coarse)")
local editable = '.cm-content:not([contenteditable="false"]):not([aria-readonly="true"])'
local readonly = '.cm-content[contenteditable="false"], .cm-content[aria-readonly="true"]'
local selector = "#sb-root #sb-main #sb-editor " .. editable .. " .button-bar"
local visible, state = "data-mobile-controls-open", "data-mobile-controls-state"
-- Lifecycle: reloading replaces all listeners and the observer.
local key = "__sbMobileCornerControlsAbort"
if win[key] then win[key].abort() end
local controller = js.new(win.AbortController)
win[key] = controller
local options = js.tojs({ capture = true, passive = false, signal = controller.signal })
local function on(name, handler) doc.addEventListener(name, handler, options) end
local function eachBar(fn)
local bars = doc.querySelectorAll(selector)
for i = 0, bars.length - 1 do fn(bars.item(i)) end
end
-- Widget identity and persistent visibility.
local function frame(bar)
return bar.closest(".sb-lua-directive-block, .sb-markdown-widget") or bar.parentElement
end
local function sync(bar)
bar.toggleAttribute(visible, frame(bar).hasAttribute(state))
end
local function toggle(bar)
frame(bar).toggleAttribute(state)
sync(bar)
end
local function findBar(target)
local selected = nil
eachBar(function(bar)
local container = frame(bar)
if container and container.contains(target)
and (not selected or frame(selected).contains(container)) then
selected = bar
end
end)
return selected
end
-- External web links keep native navigation, but must not focus the editor.
local function externalLink(e)
if not mobile.matches or not findBar(e.target) then return nil end
local el = e.target
if el.nodeType ~= 1 then el = el.parentElement end
local link = el and el.closest("a[href]:not([data-ref])")
if link and (link.protocol == "https:" or link.protocol == "http:")
and link.origin ~= win.location.origin then return link end
end
-- Return only intercepted taps: a bar plus whether this is a body tap.
-- Links, tasks, labels, and corner buttons pass through without counting.
local function hit(e)
if not mobile.matches then return nil end
local bar = findBar(e.target)
if not bar then return nil end
local el = e.target
if el.nodeType ~= 1 then el = el.parentElement end
if not el then return bar, true end
if bar.contains(el) then
if el.closest("button") then return nil end
return bar, false
end
if el.closest('a[href], a[data-ref], input[type="checkbox"], .sb-task-state[data-task-state], [role="checkbox"]') then
return nil
end
local label = el.closest("label")
if label and label.control and label.control.matches('input[type="checkbox"]') then
return nil
end
return bar, true
end
local function block(e)
if e.cancelable then e.preventDefault() end
e.stopImmediatePropagation()
end
-- One gesture, retained through its trailing click to avoid double-counting.
local gesture = nil
local function active()
if gesture and (not mobile.matches or gesture.bar.closest(readonly)) then gesture = nil end
return gesture
end
on("pointerdown", function(e)
gesture = nil
if not e.isPrimary or e.button ~= 0 then return end
if externalLink(e) then block(e); return end
local bar, body = hit(e)
if not bar then return end
gesture = { bar = bar, body = body, id = e.pointerId, x = e.clientX, y = e.clientY }
block(e)
end)
on("pointermove", function(e)
local g = active()
if g and e.pointerId == g.id then
local dx, dy = e.clientX - g.x, e.clientY - g.y
if dx * dx + dy * dy > 100 then g.moved = true end
end
end)
on("pointercancel", function(e)
if gesture and e.pointerId == gesture.id then gesture = nil end
end)
on("pointerup", function(e)
local g = active()
if g and e.pointerId == g.id then
if g.body and not g.moved and not g.done then toggle(g.bar) end
g.done = true
block(e)
end
end)
-- Preserve native scrolling/pinching; suppress editor selection and ghost clicks.
on("touchstart", function(e)
if e.touches.length ~= 1 then gesture = nil end
if active() or (e.touches.length == 1 and externalLink(e)) then
e.stopImmediatePropagation()
end
end)
on("mousedown", function(e)
if active() or (e.button == 0 and externalLink(e)) then block(e) end
end)
on("touchend", function(e)
if active() then block(e)
elseif externalLink(e) then e.stopImmediatePropagation() end
end)
on("click", function(e)
if not mobile.matches then return end
if active() then
block(e)
gesture = nil
return
end
-- Leave the default click action intact (including target/rel/download).
if externalLink(e) then e.stopImmediatePropagation(); return end
local bar, body = hit(e)
if bar then
block(e)
if body then toggle(bar) end -- Click without preceding pointer events.
end
end)
on("dblclick", function(e)
if hit(e) then block(e) end -- Both constituent taps were already counted.
end)
mobile.addEventListener("change", function() gesture = nil end, options)
-- Initialize, then restore visibility after inner-content refreshes.
eachBar(function(bar)
frame(bar).removeAttribute(state)
frame(bar).removeAttribute(visible)
bar.removeAttribute(visible)
end)
local observer = js.new(win.MutationObserver, function() eachBar(sync) end)
observer.observe(doc, js.tojs({
childList = true, subtree = true, attributes = true,
attributeFilter = { "contenteditable", "aria-readonly" }
}))
controller.signal.addEventListener("abort", function() observer.disconnect() end,
js.tojs({ once = true }))
Copy and paste both the space-lua and the space-style blocks above to enable this behavior.
2. Optional: make these widgets calmer
2.1. Suppress keyboard for "refresh" and "copy"
Optionally, there is another script that suppresses the keyboard upon clicking "refresh" or "copy":
local win, doc = js.window, js.window.document
local mobile = win.matchMedia("(hover: none) and (pointer: coarse)")
local selector = '#sb-root #sb-main #sb-editor '
.. '.cm-content:not([contenteditable="false"]):not([aria-readonly="true"]) '
.. '.button-bar button:is([data-button="copy"], [data-button="reload"])'
local key = "__sbCopyReloadNoFocusAbort"
if win[key] then win[key].abort() end
local controller = js.new(win.AbortController)
win[key] = controller
local function protect(e)
if not mobile.matches then return end
local el = e.target
if el.nodeType ~= 1 then el = el.parentElement end
if not el or not el.closest(selector) then return end
if e.touches and e.touches.length > 1 then return end
-- Cancel focus-producing defaults, but leave the eventual click intact.
if e.type == "pointerdown" or e.type == "mousedown" then
if e.button ~= 0 then return end
if e.cancelable then e.preventDefault() end
end
-- Same-document listeners (including the toggle block) still run.
e.stopPropagation()
end
local options = js.tojs({ capture = true, passive = false, signal = controller.signal })
local events = { "pointerdown", "mousedown", "touchstart", "touchend" }
for i = 1, #events do doc.addEventListener(events[i], protect, options) end
2.2. Strike through while suppressing keyboard when task is ticked inside widget
Below is a pair of scripts that enable strike-through and suppress keyboard when a todo box rendered in a widget is clicked (exactly the same behavior as when you write a task on a page and then click its todo box).
#sb-root #sb-main #sb-editor .sb-task:has(> input[type="checkbox"]:checked) {
text-decoration: line-through;
}
local win, doc = js.window, js.window.document
local selector = '#sb-root #sb-main #sb-editor .sb-task[data-external-task-ref] > input[type="checkbox"]:not(:disabled)'
-- Re-evaluating Space Lua replaces the previous listeners.
local key = "__sbQueryTaskCheckboxesAbort"
if win[key] then win[key].abort() end
local controller = js.new(win.AbortController)
win[key] = controller
local options = js.tojs({ capture = true, passive = false, signal = controller.signal })
local function on(name, handler) doc.addEventListener(name, handler, options) end
local function checkbox(e)
local el = e.target
if el and el.nodeType == 1 and el.matches(selector) then return el end
end
local function stop(e)
e.stopImmediatePropagation()
end
local function block(e)
if e.cancelable then e.preventDefault() end
stop(e)
end
-- Keep pointer/mouse events out of the surrounding editor. Leave click
-- and change alone: SilverBullet uses them to update the source task.
on("pointerdown", function(e)
if not checkbox(e) then return end
if e.pointerType == "mouse" then block(e) else stop(e) end
end)
on("pointerup", function(e) if checkbox(e) then stop(e) end end)
on("mousedown", function(e) if checkbox(e) then block(e) end end)
on("mouseup", function(e) if checkbox(e) then stop(e) end end)
-- As with page tasks, handle a touch tap before the browser focuses the
-- contenteditable editor. Do not cancel touchstart/move: scrolling works.
local touch = nil
on("touchstart", function(e)
touch = nil
local cb = checkbox(e)
if not cb then return end
stop(e)
if e.touches.length ~= 1 then return end
local t = e.touches.item(0)
touch = { cb = cb, id = t.identifier, x = t.clientX, y = t.clientY }
end)
on("touchmove", function(e)
if not touch then return end
if e.touches.length ~= 1 then touch = nil; return end
local t = e.touches.item(0)
local dx, dy = t.clientX - touch.x, t.clientY - touch.y
if t.identifier ~= touch.id or dx * dx + dy * dy > 100 then
touch.moved = true
end
stop(e)
end)
on("touchcancel", function() touch = nil end)
on("touchend", function(e)
local t = touch
touch = nil
if not t then return end
stop(e)
if t.moved or e.touches.length ~= 0 then return end
-- Prevent the trailing native click, then issue exactly one click.
-- HTMLElement.click() invokes native checkbox/save behavior without focus.
if not e.cancelable then return end
e.preventDefault()
if t.cb.isConnected and t.cb.matches(selector) then t.cb.click() end
end)
With all these changes, widgets feel more "robust" and introduce a bit less noise, e.g. previously clicking "refresh" could focus the keyboard, which by definition is not what refreshing a widget should do.
I'd be glad if you could share how you think of the code quality. Please don't hesitate to criticize them : ) I've tested it on iOS for a while and have not experienced unpleasant behavior.
