Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
165 changes: 133 additions & 32 deletions Library/Std/Widgets.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,75 +50,176 @@ local tocSchema = {
type = "object",
properties = {
minHeaders = { type = "number" },
disableTOC = { type = {"string", "boolean"} },
}
}

function widgets.toc(options)
-- Floating ToC
local REFRESH_ICON = [[<svg xmlns="http://www.w3.org/2000/svg" width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-refresh-cw"><polyline points="23 4 23 10 17 10"></polyline><polyline points="1 20 1 14 7 14"></polyline><path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"></path></svg>]]

local CLOSE_ICON = [[<svg xmlns="http://www.w3.org/2000/svg" width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-x"><line x1="18" y1="6" x2="6" y2="18"></line><line x1="6" y1="6" x2="18" y2="18"></line></svg>]]

local LIST_ICON = [[<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-list"><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>]]

local function trim(s)
if not s or type(s) ~= "string" then
return ""
end
return s:match("^%s*(.-)%s*$") or ""
end

local function escapeHtml(text)
if not text or type(text) ~= "string" then
return ""
end

local result = text
result = result:gsub("&", "&amp;")
result = result:gsub("<", "&lt;")
result = result:gsub(">", "&gt;")
result = result:gsub('"', "&quot;")

return result
end

function widgets.floatingToc(options)
options = options or {}
local validationResult = jsonschema.validateObject(tocSchema, options)
if validationResult then
error(validationResult)
end
options.minHeaders = options.minHeaders or 3
local text = editor.getText()
local pageName = editor.getCurrentPage()
local parsedMarkdown = markdown.parseMarkdown(text)
options.minHeaders = options.minHeaders or 2
options.disableTOC = options.disableTOC or false

local ok, result = pcall(function()
local text = editor.getText()
local pageName = editor.getCurrentPage()
local parsedMarkdown = markdown.parseMarkdown(text)

-- Collect all headers
local headers = {}
for topLevelChild in parsedMarkdown.children do
if topLevelChild.type then
local headerLevel = string.match(topLevelChild.type, "^ATXHeading(%d+)")
if headerLevel then
local text = ""
table.remove(topLevelChild.children, 1)
for child in topLevelChild.children do
text = text .. string.trim(markdown.renderParseTree(child))
end
if parsedMarkdown and type(parsedMarkdown) == "table" and parsedMarkdown.children and type(parsedMarkdown.children) == "table" then
for i, topLevelChild in ipairs(parsedMarkdown.children) do
if topLevelChild and type(topLevelChild) == "table" and topLevelChild.type and type(topLevelChild.type) == "string" then
local headerLevel = string.match(topLevelChild.type, "^ATXHeading(%d+)")
if headerLevel then
local text = ""
if topLevelChild.children and type(topLevelChild.children) == "table" and #topLevelChild.children > 0 then
-- Skip the first element if there's more than one element, otherwise process all
local startIndex = (#topLevelChild.children > 1) and 2 or 1
for i = startIndex, #topLevelChild.children do
local child = topLevelChild.children[i]
-- Check if child is valid and has either a type or text property
if child and type(child) == "table" and (child.type or child.text) then
local ok, renderedText = pcall(markdown.renderParseTree, child)
if ok and renderedText and type(renderedText) == "string" then
text = text .. trim(renderedText)
end
end
end
end

if text != "" then
table.insert(headers, {
name = text,
pos = topLevelChild.from,
level = headerLevel
})
if text and text ~= "" then
table.insert(headers, {
name = text,
pos = (topLevelChild.from and type(topLevelChild.from) == "number") and topLevelChild.from or 0,
level = tonumber(headerLevel) or 1
})
end
end
end
end
end

-- If not enough headers, return empty widget
if options.minHeaders and options.minHeaders > #headers then
return widget.new{}
end

-- Find min level
local minLevel = 6
for _, header in ipairs(headers) do
if header.level < minLevel then
minLevel = header.level
if header and header.level then
if header.level < minLevel then
minLevel = header.level
end
end
end
-- Build up markdown
local md = (options.header or "# Table of Contents") .. "\n"

-- Build floating ToC HTML from headers
local tocClass = (options.disableTOC == "never") and "floating-toc-always-visible" or ""
local tocHtml = [[
<div id="floating-toc" class="]] .. tocClass .. [[">
<input type="checkbox" id="toc-toggle" class="toc-toggle">
<label for="toc-toggle" class="toc-toggle-btn" title="Toggle Table of Contents">]] .. LIST_ICON .. [[</label>
<div class="toc-main">
<div class="toc-header">
<h3>Contents</h3>
<div class="toc-header-buttons">
<button class="toc-refresh-btn" data-onclick='["command", "Widgets: Refresh All"]' title="Refresh TOC">]] .. REFRESH_ICON .. [[</button>
<label for="toc-toggle" class="toc-close-btn" title="Close TOC">]] .. CLOSE_ICON .. [[</label>
</div>
</div>
<div class="toc-content">]]

-- Convert headers to HTML links
for _, header in ipairs(headers) do
if not(options.maxHeader and header.level > options.maxHeader or
if header and header.level and header.name and header.pos and
not(options.maxHeader and header.level > options.maxHeader or
options.minLevel and header.level < options.minLevel) then
md = md .. string.rep(" ", (header.level - minLevel) * 2) +
"* [[" .. pageName .. "@" .. header.pos .. "|" .. header.name .. "]]\n"
local indent = (header.level - minLevel) * 16
tocHtml = tocHtml .. [[<div class="toc-item" style="margin-left: ]] .. indent .. [[px;">]]
local escapedName = escapeHtml(header.name)
tocHtml = tocHtml .. [[<a href="/]] .. (pageName or "") .. [[@]] .. header.pos .. [[" data-ref="]] .. (pageName or "") .. [[@]] .. header.pos .. [[">]] .. escapedName .. [[</a>]]
tocHtml = tocHtml .. [[</div>]]
end
end
return widget.new {
markdown = md
}

tocHtml = tocHtml .. [[
</div>
</div>
</div>]]

return widget.new {
html = tocHtml
}
end)

if not ok then
print("ERROR in floatingToc:", result)
return widget.new {
html = '<div style="color: red; padding: 10px;">Error generating ToC: ' .. tostring(result) .. '</div>'
}
end

return result
end

event.listen {
name = "hooks:renderTopWidgets",
run = function(e)
local pageText = editor.getText()
local fm = index.extractFrontmatter(pageText)
if fm.frontmatter.pageDecoration and fm.frontmatter.pageDecoration.disableTOC then

-- Check disableTOC setting (default is false/show TOC)
local disableTOC = false
if fm.frontmatter and fm.frontmatter.pageDecoration and fm.frontmatter.pageDecoration.disableTOC ~= nil then
disableTOC = fm.frontmatter.pageDecoration.disableTOC
end

-- Don't render
if disableTOC == true or disableTOC == "true" then
return
end
return widgets.toc()

-- Floating ToC in invisible container avoids border
local floatingToc = widgets.floatingToc({ disableTOC = disableTOC })
if floatingToc.html then
return widget.new {
html = '<div class="toc-hidden-container"></div>' .. floatingToc.html
}
end
return widget.new{}
end
}
```
Expand Down
16 changes: 16 additions & 0 deletions web/styles/editor.scss
Original file line number Diff line number Diff line change
Expand Up @@ -524,6 +524,22 @@
padding: 10px;
}

/* Hide border and chrome for ToC-only widgets (Floating ToC) */
.sb-lua-top-widget:has(.toc-hidden-container) {
border: none;
margin: 0;
min-height: auto;
background: transparent;
}

.sb-lua-top-widget:has(.toc-hidden-container) .content {
padding: 0;
}

.sb-lua-top-widget:has(.toc-hidden-container) .button-bar {
display: none !important;
}

.sb-lua-bottom-widget p strong {
display: block;
padding: 10px 12px;
Expand Down
Loading