Render org links in the GUI body

Body text and list items now render org links ([[target]] and
[[target][description]]) as clickable hyperlinks via StyledText. Other
text is HTML-escaped and newlines become <br> so formatting is preserved.

Activating a link opens external URL schemes (http(s), mailto, file) in
the system handler; internal links (id:, #custom, *headline) are left for
a future navigation pass. The pointer cursor shows over hovered links.
This commit is contained in:
Levi Neely 2026-10-01 15:56:56 +02:00
parent 39ab7108ff
commit b79970b62f
1 changed files with 57 additions and 4 deletions

View File

@ -41,6 +41,45 @@ ListView {
return Math.max(outline.baseFontSize - 1, outline.baseFontSize + 5 - level) return Math.max(outline.baseFontSize - 1, outline.baseFontSize + 5 - level)
} }
// Escape HTML special chars for safe StyledText rendering.
function escHtml(s) {
return String(s)
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
}
// Render inline org markup (currently links) to StyledText. Org links are
// [[target]] or [[target][description]]; everything else is escaped. Line
// breaks are converted to <br> so StyledText preserves them.
function renderInline(text) {
let re = /\[\[([^\]\[]+?)\](?:\[([^\]]*?)\])?\]/g
let out = ""
let last = 0
let m
function escBlock(s) { return escHtml(s).replace(/\n/g, "<br>") }
while ((m = re.exec(text)) !== null) {
out += escBlock(text.slice(last, m.index))
let target = m[1]
let desc = (m[2] !== undefined && m[2] !== "") ? m[2] : m[1]
let href = escHtml(target).replace(/"/g, "&quot;")
out += '<a href="' + href + '">' + escHtml(desc) + '</a>'
last = re.lastIndex
}
out += escBlock(text.slice(last))
return out
}
// Open a link target activated from body text. External URL schemes open
// in the system handler; internal org links (id:, #custom, *headline) are
// left for a future navigation pass.
function openLink(target) {
if (/^([a-zA-Z][a-zA-Z0-9+.-]*):\/\//.test(target)
|| /^(mailto:|file:)/.test(target)) {
Qt.openUrlExternally(target)
}
}
delegate: Rectangle { delegate: Rectangle {
id: del id: del
width: outline.width width: outline.width
@ -427,12 +466,19 @@ ListView {
} }
Label { Label {
Layout.fillWidth: true Layout.fillWidth: true
text: modelData.content text: outline.renderInline(modelData.content)
color: theme.placeholderTextColor color: theme.placeholderTextColor
linkColor: theme.linkColor
font.pointSize: outline.baseFontSize + 1 font.pointSize: outline.baseFontSize + 1
lineHeight: 1.3 lineHeight: 1.3
wrapMode: Text.WordWrap wrapMode: Text.WordWrap
textFormat: Text.PlainText textFormat: Text.StyledText
onLinkActivated: (link) => outline.openLink(link)
HoverHandler {
enabled: parent.hoveredLink !== ""
cursorShape: Qt.PointingHandCursor
}
} }
} }
} }
@ -497,13 +543,20 @@ ListView {
// Item text; checked items are dimmed and struck through. // Item text; checked items are dimmed and struck through.
Label { Label {
Layout.fillWidth: true Layout.fillWidth: true
text: modelData.text ?? "" text: outline.renderInline(modelData.text ?? "")
color: (modelData.checkbox === "checked") color: (modelData.checkbox === "checked")
? theme.placeholderTextColor : theme.textColor ? theme.placeholderTextColor : theme.textColor
linkColor: theme.linkColor
font.pointSize: outline.baseFontSize + 1 font.pointSize: outline.baseFontSize + 1
font.strikeout: modelData.checkbox === "checked" font.strikeout: modelData.checkbox === "checked"
wrapMode: Text.WordWrap wrapMode: Text.WordWrap
textFormat: Text.PlainText textFormat: Text.StyledText
onLinkActivated: (link) => outline.openLink(link)
HoverHandler {
enabled: parent.hoveredLink !== ""
cursorShape: Qt.PointingHandCursor
}
} }
} }
} }