#meta

Implements some useful general purpose widgets. Specifically:

Buttons

Types of button widgets:

  • widgets.button(text, callback) renders a simple button running the callback when clicked
  • widgets.commandButton(commandName) renders a button for a particular command (where the button text is the command name itself)
  • widgets.commandButton(text, commandName) renders a button for a particular command with a custom button text
  • widgets.commandButton(text, commandName, args) renders a button for a particular command and arguments (specified as a table list) with a custom button text

Examples:

${widgets.button("Hello", function() editor.flashNotification "Hi there!" end)}

${widgets.commandButton("System: Reload")}

Top and bottom widgets

  • Table of contents: shows a table of contents for your page
  • Linked mentions: show a list of links that link to the current page, at the bottom of your page
  • Linked tasks: shows a list of tasks that link to the current page, at the top of the page

These can each be individually enabled/disabled and configured in your CONFIG page (use space-lua instead of lua):

-- Disable TOC altogether
config.set("std.widgets.toc.enabled", false)
-- Only render a TOC when there's >= 5 headers
config.set("std.widgets.toc.minHeaders", 5)
-- Disable linked mentions altogether
config.set("std.widgets.linkedMentions.enabled", false)
-- Disable linked tasks altogether
config.set("std.widgets.linkedTasks.enabled", false)

Implementation

Buttons

-- priority: 10
function widgets.button(text, callback, attrs)
  local buttonEl = {
    onclick = callback,
    text
  }

  -- attrs can be used for additional customization
  if attrs then
    for k, v in pairs(attrs) do
      buttonEl[k] = v
    end
  end

  return widget.html(dom.button(buttonEl))
end

function widgets.commandButton(text, commandName, args)
  if not commandName then
    -- When only passed one argument, then let's assume it's a command name
    commandName = text
  end
  return widget.html(dom.button {
    onclick = function()
      editor.invokeCommand(commandName, args)
    end,
    text
  })
end

function widgets.subPages(pageName)
  local prefix = (pageName or editor.getCurrentPage()) .. "/"
  return widget.markdown(template.each(query[
    from index.tag "page"
    where string.startsWith(_.name, prefix)
  ](
    from index.tag "page"
    where string.startsWith(_.name, prefix)
  ), templates.pageItem))
end

Table of contents

.sb-toc-summary {
  cursor: pointer;
  font-weight: bold;
  user-select: none;
  padding: 15px 10px;
  margin: -10px;
  background-color: var(--editor-widget-background-color);
}
.sb-toc-content {
  padding-top: 0.5rem;
}
.sb-toc-item {
  padding: 0.1rem 0;
}
.sb-toc-link {
  cursor: pointer;
  text-decoration: none;
}
-- priority: 10
widgets = widgets or {}

-- configuration schema
config.define("std.widgets.toc", {
  type = "object",
  properties = {
    enabled = schema.boolean(),
    minHeaders = schema.number(),
  }
})

-- configuration default values
config.set("std.widgets.toc", {
  enabled = true,
  minHeaders = 3
})

function widgets.toc(options)
  options = options or config.get("std.widgets.toc")
  options.minHeaders = options.minHeaders or 3
  options.minLevel = options.minLevel or 1
  options.header = options.header or "Table of Contents"
  local defaultOpen = (options.defaultOpen ~= false) or nil

  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
        -- Strip link syntax to avoid nested brackets in TOC
        text = string.gsub(text, "%[%[(.-)%]%]", "%1")

        if text != "" then
          table.insert(headers, {
            name = text,
            pos = topLevelChild.from,
            level = tonumber(headerLevel)
          })
        end
      end
    end
  end

  if options.minHeaders and options.minHeaders > #headers then
    return widget.new{}
  end

  -- Filter headers to display
  local headersToDisplay = {}
  for _, header in ipairs(headers) do
    if not (options.maxHeader and header.level > options.maxHeader or
            header.level < options.minLevel) then
      table.insert(headersToDisplay, header)
    end
  end
  
  -- Find min level
  local minLevel = 6
  for _, header in ipairs(headersToDisplay) do
    minLevel = math.min(minLevel, header.level)
  end

  -- Build TOC entries as DOM elements
  local tocItems = {}
  for _, header in ipairs(headersToDisplay) do
    local indent = (header.level - minLevel) * 1.5
    table.insert(tocItems, dom.div {
      class = "sb-toc-item",
      style = "margin-left: " .. indent .. "rem;",
      dom.a {
        onclick = function()
          editor.navigate({ page = pageName, pos = header.pos })
        end,
        class = "sb-toc-link",
        "- " .. header.name
      }
    })
  end

  -- Wrap in a <details> element for native show/hide toggle
  return widget.new {
    html = dom.details {
      open = defaultOpen,
      dom.summary {
        class = "sb-toc-summary",
        options.header
      },
      dom.div {
        class = "sb-toc-content",
        table.unpack(tocItems)
      }
    },
    display = "block"
  }
end

Top widget

-- priority: -1
if config.get("std.widgets.toc.enabled") then
  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
        return
      end
      return widgets.toc()
    end
  }
end

Linked mentions

-- priority: 10
widgets = widgets or {}

local mentionTemplate = template.new [==[
**[${_.ref}|${_.ref}](${_.ref}|${_.ref})**:
${_.snippet}

]==]

-- configuration schema
config.define("std.widgets.linkedMentions", {
  type = "object",
  properties = {
    enabled = schema.boolean(),
  }
})

-- configuration default values
config.set("std.widgets.linkedMentions", {
  enabled = true,
})

function widgets.linkedMentions(pageName)
  pageName = pageName or editor.getCurrentPage()
  local linkedMentions = query[
    from l = index.tag "link"
    where l.page != pageName and l.toPage == pageName
    order by l.pageLastModified desc, l.pos
  ](
    from l = index.tag "link"
    where l.page != pageName and l.toPage == pageName
    order by l.pageLastModified desc, l.pos
  )
  if #linkedMentions > 0 then
    return widget.new {
      markdown = "# Linked Mentions\n"
        .. template.each(linkedMentions, mentionTemplate)
    }
  end
end

Bottom widget

-- priority: -1
if config.get("std.widgets.linkedMentions.enabled") then
  event.listen {
    name = "hooks:renderBottomWidgets",
    run = function(e)
      return widgets.linkedMentions()
    end
  }
end

Linked tasks

-- priority: 10

-- configuration schema
config.define("std.widgets.linkedTasks", {
  type = "object",
  properties = {
    enabled = schema.boolean(),
  }
})

-- configuration default values
config.set("std.widgets.linkedTasks", {
  enabled = true,
})

function widgets.linkedTasks(pageName)
  pageName = pageName or editor.getCurrentPage()
  local tasks = query[
    from t = index.tag "task"
    where not t.done and table.includes(t.ilinks, pageName)
    order by t.page
  ](
    from t = index.tag "task"
    where not t.done and table.includes(t.ilinks, pageName)
    order by t.page
  )
  local md = ""
  if #tasks > 0 then
    md = "# Linked Tasks\n"
       .. template.each(tasks, templates.taskItem)
  else
    md = ""
  end
  return widget.new {
    markdown = md
  }
end

Top widget

-- priority: -1
if config.get("std.widgets.linkedTasks.enabled") then
  event.listen {
    name = "hooks:renderTopWidgets",
    run = function(e)
      return widgets.linkedTasks()
    end
  }
end