1329 lines
61 KiB
QML
1329 lines
61 KiB
QML
import QtQuick
|
||
import QtQuick.Controls
|
||
import QtQuick.Layouts
|
||
|
||
// Document outline rendered as a flat ListView with indentation.
|
||
// Each section is a row; nesting is shown via left margin.
|
||
// This gives us full control over layout, selection, and editing.
|
||
ListView {
|
||
id: outline
|
||
clip: true
|
||
spacing: 4
|
||
|
||
readonly property int rowPad: 6
|
||
readonly property int baseFontSize: theme.fontSize > 0 ? theme.fontSize : 12
|
||
readonly property int indentWidth: 20
|
||
|
||
// When a body editor requests a timestamp insertion, it registers itself
|
||
// here so the shared date picker can insert into it at the cursor.
|
||
property var insertTarget: null
|
||
|
||
// ── Drag-and-drop structural move state ──────────────────────────────
|
||
// The visible row currently being dragged (-1 = none), the live resolved
|
||
// drop target from docModel.resolveDrop, and the gap geometry for drawing
|
||
// the insertion indicator.
|
||
property int dragRow: -1
|
||
property var dropInfo: null // {valid, level, parentId, parentTitle, beforeId}
|
||
property real dropIndicatorY: 0 // content-y of the insertion bar
|
||
property int dropGapAbove: -2 // visible row above the gap (-1 = top)
|
||
|
||
// Recompute the drop target from a pointer position in content coords.
|
||
function updateDrop(contentX, contentY) {
|
||
if (dragRow < 0) return
|
||
// Which row is the pointer over? The gap is above or below its middle.
|
||
var r = indexAt(1, contentY)
|
||
if (r < 0) {
|
||
// Past the last row → gap at the very bottom.
|
||
r = count - 1
|
||
}
|
||
var item = itemAtIndex(r)
|
||
var gapAbove
|
||
var gapY
|
||
if (item) {
|
||
var mid = item.y + item.height / 2
|
||
if (contentY < mid) { gapAbove = r - 1; gapY = item.y }
|
||
else { gapAbove = r; gapY = item.y + item.height }
|
||
} else {
|
||
gapAbove = r; gapY = contentY
|
||
}
|
||
// Desired level from horizontal indent. Row left padding is
|
||
// indent(level) + 8; one level ≈ indentWidth px.
|
||
var desired = Math.round((contentX - 8) / outline.indentWidth) + 1
|
||
if (desired < 1) desired = 1
|
||
var info = docModel.resolveDrop(dragRow, gapAbove, desired)
|
||
outline.dropInfo = info
|
||
outline.dropGapAbove = gapAbove
|
||
outline.dropIndicatorY = gapY
|
||
}
|
||
|
||
// Commit the current drop (called on drag release).
|
||
function commitDrop() {
|
||
if (dragRow >= 0 && dropInfo && dropInfo.valid) {
|
||
docModel.refileSection(dragRow, dropInfo.parentId, dropInfo.beforeId)
|
||
}
|
||
dragRow = -1
|
||
dropInfo = null
|
||
dropGapAbove = -2
|
||
}
|
||
|
||
// Palette for TODO keywords by state.
|
||
function keywordColor(kw) {
|
||
switch (kw) {
|
||
case "TODO": return theme.todoColor
|
||
case "NEXT": return theme.todoColor
|
||
case "WAITING": return theme.waitingColor
|
||
case "DONE": return theme.doneColor
|
||
case "CANCELLED": return theme.cancelledColor
|
||
default: return theme.linkColor
|
||
}
|
||
}
|
||
|
||
// Priority color by level.
|
||
function priorityColor(pri) {
|
||
switch (pri) {
|
||
case "A": return theme.priorityAColor
|
||
case "B": return theme.priorityBColor
|
||
case "C": return theme.priorityCColor
|
||
default: return theme.textColor
|
||
}
|
||
}
|
||
|
||
// Headline text scales down slightly with depth.
|
||
function levelFontSize(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, "&")
|
||
.replace(/</g, "<")
|
||
.replace(/>/g, ">")
|
||
}
|
||
|
||
// 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, """)
|
||
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. Resolution and dispatch
|
||
// happen in the model (via /<doc>/resolve): external URLs open in the system
|
||
// handler; internal links reveal the target section in the outline.
|
||
function openLink(target) {
|
||
docModel.followLink(target)
|
||
}
|
||
|
||
// Row currently flashing from a reveal (−1 = none).
|
||
property int flashRow: -1
|
||
|
||
// When the model reveals a section, select it, scroll it into view, and
|
||
// flash it briefly. `bodyOffset` is a byte offset into the section's body.
|
||
// When the match lies inside the body (offset > 0), position the row at the
|
||
// top of the view, then scroll further so the body segment containing the
|
||
// offset sits at the top (segment-precise). Otherwise just ensure the row
|
||
// is contained.
|
||
Connections {
|
||
target: docModel
|
||
function onSectionRevealed(row, bodyOffset) {
|
||
if (row < 0)
|
||
return
|
||
outline.currentIndex = row
|
||
if (bodyOffset > 0) {
|
||
// Bring the row's top to the viewport top first; this also
|
||
// realizes the delegate so its segments can be measured.
|
||
outline.positionViewAtIndex(row, ListView.Beginning)
|
||
let item = outline.itemAtIndex(row)
|
||
if (item && item.offsetToY) {
|
||
let extra = item.offsetToY(bodyOffset)
|
||
let maxY = Math.max(outline.originY,
|
||
outline.contentHeight - outline.height)
|
||
outline.contentY = Math.min(outline.contentY + extra, maxY)
|
||
}
|
||
} else {
|
||
outline.positionViewAtIndex(row, ListView.Contain)
|
||
}
|
||
outline.flashRow = row
|
||
flashTimer.restart()
|
||
}
|
||
}
|
||
|
||
Timer {
|
||
id: flashTimer
|
||
interval: 900
|
||
onTriggered: outline.flashRow = -1
|
||
}
|
||
|
||
// Shared date picker for SCHEDULED/DEADLINE editing and body-timestamp
|
||
// insertion. Opened via datePicker.open2(row, field, value) for planning
|
||
// fields, or datePicker.openInsert() with outline.insertTarget set for
|
||
// inserting a timestamp into a body editor at the cursor.
|
||
DatePickerDialog {
|
||
id: datePicker
|
||
parent: Overlay.overlay
|
||
onTimestampAccepted: (ts) => {
|
||
if (outline.insertTarget) {
|
||
outline.insertTarget.insert(outline.insertTarget.cursorPosition, ts)
|
||
outline.insertTarget.forceActiveFocus()
|
||
}
|
||
}
|
||
}
|
||
|
||
// Drop indicator shown while dragging a section. A horizontal insertion
|
||
// bar at the resolved gap, indented to the resolved level, with a ghost
|
||
// label naming the resolved level and parent. Driven entirely by
|
||
// outline.dropInfo so the preview matches exactly what commitDrop() does.
|
||
Item {
|
||
id: dropIndicator
|
||
z: 100
|
||
visible: outline.dragRow >= 0 && outline.dropInfo && outline.dropInfo.valid
|
||
// Convert the content-y gap position to viewport coordinates.
|
||
y: outline.dropIndicatorY - outline.contentY
|
||
x: 0
|
||
width: outline.width
|
||
height: 2
|
||
|
||
// Indent of the insertion bar = left padding for the resolved level.
|
||
readonly property int barIndent: visible
|
||
? 8 + (outline.dropInfo.level - 1) * outline.indentWidth : 0
|
||
|
||
Rectangle {
|
||
x: dropIndicator.barIndent
|
||
width: parent.width - x - 8
|
||
height: 2
|
||
y: -1
|
||
color: theme.linkColor
|
||
radius: 1
|
||
}
|
||
// Left cap circle at the insertion point.
|
||
Rectangle {
|
||
x: dropIndicator.barIndent - 3
|
||
y: -3
|
||
width: 6; height: 6; radius: 3
|
||
color: theme.linkColor
|
||
}
|
||
// Ghost label: "L{level} under \"{parent}\"" (or "top level").
|
||
Rectangle {
|
||
x: dropIndicator.barIndent
|
||
y: 2
|
||
width: ghostLabel.implicitWidth + 8
|
||
height: ghostLabel.implicitHeight + 2
|
||
radius: 3
|
||
color: theme.windowColor
|
||
border.color: theme.linkColor
|
||
border.width: 1
|
||
Label {
|
||
id: ghostLabel
|
||
anchors.centerIn: parent
|
||
text: {
|
||
if (!outline.dropInfo || !outline.dropInfo.valid) return ""
|
||
var lvl = "L" + outline.dropInfo.level
|
||
var p = (outline.dropInfo.parentTitle && outline.dropInfo.parentTitle.length > 0)
|
||
? qsTr("under \"%1\"").arg(outline.dropInfo.parentTitle)
|
||
: qsTr("at top level")
|
||
return lvl + " " + p
|
||
}
|
||
color: theme.textColor
|
||
font.pointSize: outline.baseFontSize - 3
|
||
}
|
||
}
|
||
}
|
||
|
||
delegate: Rectangle {
|
||
id: del
|
||
width: outline.width
|
||
implicitHeight: contentColumn.implicitHeight + outline.rowPad * 2
|
||
color: ListView.isCurrentItem ? Qt.rgba(theme.highlightColor.r, theme.highlightColor.g, theme.highlightColor.b, 0.2) : "transparent"
|
||
radius: 4
|
||
|
||
required property int index
|
||
required property var model
|
||
|
||
property bool drawerOpen: false
|
||
|
||
// Inline tag editing: when true, the tag pills are replaced by a text
|
||
// field prefilled with the current tags.
|
||
property bool editingTags: false
|
||
|
||
// Inline body editing: when true, the rendered body segments are
|
||
// replaced by a multi-line text editor seeded with the raw body.
|
||
property bool editingBody: false
|
||
|
||
// Transient flash overlay when this row is revealed via a link.
|
||
Rectangle {
|
||
anchors.fill: parent
|
||
radius: parent.radius
|
||
color: theme.linkColor
|
||
z: -1
|
||
opacity: outline.flashRow === del.index ? 0.35 : 0.0
|
||
Behavior on opacity { NumberAnimation { duration: 300 } }
|
||
}
|
||
|
||
readonly property string sectionBody: (model.body ?? "").trim()
|
||
readonly property int sectionLevel: model.level ?? 1
|
||
readonly property int indent: (sectionLevel - 1) * outline.indentWidth
|
||
|
||
// Parse body into text, code block, and table segments
|
||
readonly property var bodyParts: {
|
||
let parts = [];
|
||
let body = sectionBody;
|
||
if (!body) return parts;
|
||
|
||
// Match an org list item, capturing indent, bullet/number, optional
|
||
// checkbox, and the item text. Returns null for non-list lines.
|
||
function parseListItem(line) {
|
||
// Unordered: - / + bullet (indented * also allowed)
|
||
let m = line.match(/^(\s*)([-+])\s+(\[([ xX-])\]\s*)?(.*)$/);
|
||
let ordered = false;
|
||
if (!m) {
|
||
// Ordered: 1. or 1)
|
||
m = line.match(/^(\s*)(\d+)[.)]\s+(\[([ xX-])\]\s*)?(.*)$/);
|
||
if (!m) return null;
|
||
ordered = true;
|
||
}
|
||
let cb = "";
|
||
if (m[3] !== undefined) {
|
||
switch ((m[4] || " ")) {
|
||
case "x": case "X": cb = "checked"; break;
|
||
case "-": cb = "partial"; break;
|
||
default: cb = "unchecked"; break;
|
||
}
|
||
}
|
||
return {
|
||
indent: m[1].length,
|
||
ordered: ordered,
|
||
bullet: m[2],
|
||
checkbox: cb,
|
||
text: m[5]
|
||
};
|
||
}
|
||
|
||
// Push a run of text, splitting out any contiguous list items into
|
||
// dedicated "list" parts so they render with bullets/checkboxes.
|
||
// `cbCounter` is a single-element array used as a mutable counter so
|
||
// checkbox ordinals are stable across the whole section body.
|
||
// `baseLine` is the body line index of the first line of `text`,
|
||
// so each emitted part records the body line where it begins
|
||
// (used for char/offset-precise scrolling).
|
||
function pushText(text, cbCounter, baseLine) {
|
||
if (!text) return;
|
||
let lines = text.split('\n');
|
||
let buf = [];
|
||
let items = [];
|
||
let bufStart = -1;
|
||
let itemsStart = -1;
|
||
function flushBuf() {
|
||
if (buf.length > 0) {
|
||
let t = buf.join('\n').trim();
|
||
if (t) parts.push({type: "text", content: t, startLine: bufStart});
|
||
buf = [];
|
||
}
|
||
}
|
||
function flushItems() {
|
||
if (items.length > 0) {
|
||
parts.push({type: "list", items: items, startLine: itemsStart});
|
||
items = [];
|
||
}
|
||
}
|
||
for (let li = 0; li < lines.length; li++) {
|
||
let ln = lines[li];
|
||
let item = parseListItem(ln);
|
||
if (item) {
|
||
flushBuf();
|
||
if (items.length === 0) itemsStart = baseLine + li;
|
||
// Assign a stable ordinal to checkbox-bearing items.
|
||
item.checkboxOrdinal = (item.checkbox !== "")
|
||
? cbCounter[0]++ : -1;
|
||
items.push(item);
|
||
} else {
|
||
flushItems();
|
||
if (buf.length === 0) bufStart = baseLine + li;
|
||
buf.push(ln);
|
||
}
|
||
}
|
||
flushItems();
|
||
flushBuf();
|
||
}
|
||
|
||
// Running checkbox ordinal across the whole section body.
|
||
let cbCounter = [0];
|
||
|
||
let lines = body.split('\n');
|
||
let currentText = [];
|
||
let textRunStart = -1; // body line index where currentText began
|
||
let inCode = false;
|
||
let codeLang = "";
|
||
let codeLines = [];
|
||
let codeStart = -1; // body line index of the #+BEGIN_SRC
|
||
let codeCounter = 0;
|
||
let inTable = false;
|
||
let tableLines = [];
|
||
let tableStart = -1; // body line index of the first table row
|
||
|
||
// Append a line to the pending text run, recording its start line.
|
||
function pushCurrentText(i) {
|
||
if (currentText.length === 0) textRunStart = i;
|
||
currentText.push(lines[i]);
|
||
}
|
||
|
||
for (let i = 0; i < lines.length; i++) {
|
||
let line = lines[i];
|
||
let trimmed = line.trim();
|
||
|
||
// Code block handling
|
||
if (!inCode && !inTable && trimmed.toUpperCase().startsWith("#+BEGIN_SRC")) {
|
||
// Flush pending text
|
||
if (currentText.length > 0) {
|
||
pushText(currentText.join('\n').trim(), cbCounter, textRunStart);
|
||
currentText = [];
|
||
}
|
||
inCode = true;
|
||
codeStart = i;
|
||
codeLang = trimmed.substring(11).trim().split(/\s+/)[0] || "";
|
||
codeLines = [];
|
||
} else if (inCode && trimmed.toUpperCase() === "#+END_SRC") {
|
||
parts.push({type: "code", language: codeLang, content: codeLines.join('\n'), codeIndex: codeCounter, startLine: codeStart});
|
||
codeCounter++;
|
||
inCode = false;
|
||
codeLang = "";
|
||
codeLines = [];
|
||
} else if (inCode) {
|
||
codeLines.push(line);
|
||
}
|
||
// Table handling
|
||
else if (!inTable && trimmed.startsWith("|")) {
|
||
// Flush pending text
|
||
if (currentText.length > 0) {
|
||
pushText(currentText.join('\n').trim(), cbCounter, textRunStart);
|
||
currentText = [];
|
||
}
|
||
inTable = true;
|
||
tableStart = i;
|
||
tableLines = [trimmed];
|
||
} else if (inTable && trimmed.startsWith("|")) {
|
||
tableLines.push(trimmed);
|
||
} else if (inTable) {
|
||
// End of table - skip #+TBLFM lines
|
||
if (!trimmed.startsWith("#+TBLFM")) {
|
||
// Parse table
|
||
let rows = [];
|
||
let hasHeader = false;
|
||
for (let tl of tableLines) {
|
||
if (tl.match(/^\|\s*[-+]+\s*\|/)) {
|
||
hasHeader = rows.length > 0;
|
||
} else {
|
||
let cells = tl.split("|").slice(1, -1).map(c => c.trim());
|
||
rows.push(cells);
|
||
}
|
||
}
|
||
parts.push({type: "table", rows: rows, hasHeader: hasHeader, startLine: tableStart});
|
||
tableLines = [];
|
||
inTable = false;
|
||
|
||
// Process current line as text if not empty
|
||
if (trimmed) pushCurrentText(i);
|
||
}
|
||
} else {
|
||
pushCurrentText(i);
|
||
}
|
||
}
|
||
|
||
// Flush remaining
|
||
if (inCode && codeLines.length > 0) {
|
||
parts.push({type: "code", language: codeLang, content: codeLines.join('\n'), codeIndex: codeCounter, startLine: codeStart});
|
||
codeCounter++;
|
||
} else if (inTable && tableLines.length > 0) {
|
||
let rows = [];
|
||
let hasHeader = false;
|
||
for (let tl of tableLines) {
|
||
if (tl.match(/^\|\s*[-+]+\s*\|/)) {
|
||
hasHeader = rows.length > 0;
|
||
} else {
|
||
let cells = tl.split("|").slice(1, -1).map(c => c.trim());
|
||
rows.push(cells);
|
||
}
|
||
}
|
||
parts.push({type: "table", rows: rows, hasHeader: hasHeader, startLine: tableStart});
|
||
} else if (currentText.length > 0) {
|
||
pushText(currentText.join('\n').trim(), cbCounter, textRunStart);
|
||
}
|
||
|
||
return parts;
|
||
}
|
||
|
||
// Map a body byte offset to a y position (relative to this delegate's
|
||
// top) by locating the body segment that contains the offset's line and
|
||
// returning that segment's rendered y. This keeps the segmented
|
||
// rendering while scrolling precisely to the matched segment rather
|
||
// than only the section top. Returns 0 when there is no better target.
|
||
function offsetToY(bodyOffset) {
|
||
if (bodyOffset <= 0 || bodyParts.length === 0)
|
||
return 0;
|
||
// Target line = number of newlines before the offset in the body.
|
||
let body = sectionBody;
|
||
let clamped = Math.min(bodyOffset, body.length);
|
||
let targetLine = 0;
|
||
for (let k = 0; k < clamped; k++)
|
||
if (body.charCodeAt(k) === 10) targetLine++;
|
||
// Find the last part whose startLine <= targetLine (parts are in
|
||
// document order with non-decreasing startLine).
|
||
let pIdx = -1;
|
||
for (let p = 0; p < bodyParts.length; p++) {
|
||
let sl = bodyParts[p].startLine;
|
||
if (sl === undefined || sl < 0) continue;
|
||
if (sl <= targetLine) pIdx = p; else break;
|
||
}
|
||
if (pIdx < 0)
|
||
return 0;
|
||
let item = bodyRepeater.itemAt(pIdx);
|
||
if (!item)
|
||
return 0;
|
||
// y of the segment relative to this delegate.
|
||
return item.mapToItem(del, 0, 0).y;
|
||
}
|
||
|
||
MouseArea {
|
||
anchors.fill: parent
|
||
onClicked: outline.currentIndex = del.index
|
||
}
|
||
|
||
ColumnLayout {
|
||
id: contentColumn
|
||
anchors.left: parent.left
|
||
anchors.right: parent.right
|
||
anchors.top: parent.top
|
||
anchors.leftMargin: del.indent + 8
|
||
anchors.rightMargin: 8
|
||
anchors.topMargin: outline.rowPad
|
||
spacing: 4
|
||
|
||
RowLayout {
|
||
id: headlineRow
|
||
Layout.fillWidth: true
|
||
spacing: 8
|
||
|
||
// Drag grip: drag a section (and its subtree) to restructure.
|
||
// A live indicator shows the resolved drop target; releasing
|
||
// commits a refile. Horizontal position chooses the depth,
|
||
// vertical position chooses the gap.
|
||
Label {
|
||
text: "⠿"
|
||
color: theme.placeholderTextColor
|
||
font.pointSize: outline.baseFontSize - 1
|
||
opacity: gripMouse.containsMouse || outline.dragRow === del.index ? 1.0 : 0.3
|
||
|
||
MouseArea {
|
||
id: gripMouse
|
||
anchors.fill: parent
|
||
hoverEnabled: true
|
||
cursorShape: Qt.SizeAllCursor
|
||
preventStealing: true
|
||
onPressed: {
|
||
outline.dragRow = del.index
|
||
}
|
||
onPositionChanged: (mouse) => {
|
||
if (outline.dragRow < 0) return
|
||
// Map the pointer to ListView content coordinates.
|
||
var p = mapToItem(outline.contentItem, mouse.x, mouse.y)
|
||
outline.updateDrop(p.x, p.y)
|
||
}
|
||
onReleased: outline.commitDrop()
|
||
onCanceled: {
|
||
outline.dragRow = -1
|
||
outline.dropInfo = null
|
||
}
|
||
}
|
||
}
|
||
|
||
// Expand/collapse indicator for sections with children
|
||
Label {
|
||
visible: (del.model.hasChildren ?? false)
|
||
text: (del.model.expanded ?? true) ? "▼" : "▶"
|
||
color: theme.placeholderTextColor
|
||
font.pointSize: outline.baseFontSize - 2
|
||
|
||
MouseArea {
|
||
anchors.fill: parent
|
||
onClicked: {
|
||
// Toggle expansion via model
|
||
if (del.model.expanded !== undefined) {
|
||
docModel.toggleExpanded(del.index)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// TODO keyword pill. Left-click / wheel cycles the keyword
|
||
// (TODO -> NEXT -> ... -> DONE -> none -> TODO); right-click
|
||
// opens a menu to pick a state directly. The pill stays present
|
||
// even when there is no keyword (shown as a faint placeholder),
|
||
// so a keyword can always be re-added. Each change is one 9P
|
||
// write via the model.
|
||
Rectangle {
|
||
id: kwPill
|
||
readonly property string kw: del.model.keyword ?? ""
|
||
readonly property bool hasKw: kw !== ""
|
||
radius: 3
|
||
color: kwMouse.containsMouse ? Qt.rgba(
|
||
outline.keywordColor(kw).r,
|
||
outline.keywordColor(kw).g,
|
||
outline.keywordColor(kw).b, 0.15)
|
||
: "transparent"
|
||
border.color: hasKw ? outline.keywordColor(kw)
|
||
: theme.placeholderTextColor
|
||
border.width: 1
|
||
// Faint when empty unless hovered, so it stays discoverable
|
||
// without cluttering headlines that have no keyword.
|
||
opacity: hasKw ? 1.0 : (kwMouse.containsMouse ? 0.9 : 0.35)
|
||
implicitWidth: kwLabel.implicitWidth + 10
|
||
implicitHeight: kwLabel.implicitHeight + 4
|
||
Label {
|
||
id: kwLabel
|
||
anchors.centerIn: parent
|
||
text: kwPill.hasKw ? kwPill.kw : "+"
|
||
color: kwPill.hasKw ? outline.keywordColor(kwPill.kw)
|
||
: theme.placeholderTextColor
|
||
font.pointSize: outline.baseFontSize - 1
|
||
font.bold: true
|
||
}
|
||
|
||
MouseArea {
|
||
id: kwMouse
|
||
anchors.fill: parent
|
||
hoverEnabled: true
|
||
cursorShape: Qt.PointingHandCursor
|
||
acceptedButtons: Qt.LeftButton | Qt.RightButton
|
||
onClicked: (mouse) => {
|
||
if (mouse.button === Qt.RightButton)
|
||
kwMenu.openFor(del.index)
|
||
else
|
||
docModel.cycleKeyword(del.index, true)
|
||
}
|
||
onWheel: (wheel) => {
|
||
docModel.cycleKeyword(del.index, wheel.angleDelta.y > 0)
|
||
wheel.accepted = true
|
||
}
|
||
}
|
||
|
||
// Keyword chooser: pick a state directly, or clear it.
|
||
// Populated from the document's keyword set on open.
|
||
Menu {
|
||
id: kwMenu
|
||
property int targetRow: -1
|
||
property var choices: []
|
||
function openFor(row) {
|
||
targetRow = row
|
||
choices = docModel.keywordChoices()
|
||
popup()
|
||
}
|
||
Repeater {
|
||
model: kwMenu.choices
|
||
delegate: MenuItem {
|
||
required property string modelData
|
||
text: modelData
|
||
onTriggered: docModel.setKeyword(kwMenu.targetRow, modelData)
|
||
}
|
||
}
|
||
MenuSeparator {}
|
||
MenuItem {
|
||
text: qsTr("None")
|
||
onTriggered: docModel.setKeyword(kwMenu.targetRow, "")
|
||
}
|
||
}
|
||
}
|
||
|
||
// Priority cookie. Left-click / wheel cycles
|
||
// (none) -> A -> B -> C -> (none); right-click opens a menu to
|
||
// pick directly. Stays present (faint placeholder) when empty
|
||
// so a priority can be re-added. One 9P write per change.
|
||
Rectangle {
|
||
id: prioPill
|
||
readonly property string pri: del.model.priority ?? ""
|
||
readonly property bool hasPri: pri !== ""
|
||
radius: 3
|
||
color: "transparent"
|
||
opacity: hasPri ? 1.0 : (prioMouse.containsMouse ? 0.9 : 0.3)
|
||
implicitWidth: prioLabel.implicitWidth + 4
|
||
implicitHeight: prioLabel.implicitHeight + 2
|
||
Label {
|
||
id: prioLabel
|
||
anchors.centerIn: parent
|
||
text: prioPill.hasPri ? "[#" + prioPill.pri + "]" : "[#]"
|
||
color: prioPill.hasPri ? outline.priorityColor(prioPill.pri)
|
||
: theme.placeholderTextColor
|
||
font.pointSize: outline.baseFontSize
|
||
font.bold: true
|
||
}
|
||
|
||
MouseArea {
|
||
id: prioMouse
|
||
anchors.fill: parent
|
||
hoverEnabled: true
|
||
cursorShape: Qt.PointingHandCursor
|
||
acceptedButtons: Qt.LeftButton | Qt.RightButton
|
||
onClicked: (mouse) => {
|
||
if (mouse.button === Qt.RightButton)
|
||
prioMenu.popup()
|
||
else
|
||
docModel.cyclePriority(del.index, true)
|
||
}
|
||
onWheel: (wheel) => {
|
||
docModel.cyclePriority(del.index, wheel.angleDelta.y > 0)
|
||
wheel.accepted = true
|
||
}
|
||
}
|
||
|
||
// Priority chooser: pick A/B/C directly, or clear it.
|
||
Menu {
|
||
id: prioMenu
|
||
MenuItem { text: "A"; onTriggered: docModel.setPriority(del.index, "A") }
|
||
MenuItem { text: "B"; onTriggered: docModel.setPriority(del.index, "B") }
|
||
MenuItem { text: "C"; onTriggered: docModel.setPriority(del.index, "C") }
|
||
MenuSeparator {}
|
||
MenuItem { text: qsTr("None"); onTriggered: docModel.setPriority(del.index, "") }
|
||
}
|
||
}
|
||
|
||
// Title. Rendered as StyledText so inline org links in the
|
||
// headline are clickable; non-link text keeps the done-aware
|
||
// color and strike-through.
|
||
Label {
|
||
text: outline.renderInline(del.model.title ?? "")
|
||
color: (del.model.isDone ?? false) ? theme.placeholderTextColor : theme.textColor
|
||
linkColor: theme.linkColor
|
||
font.pointSize: outline.levelFontSize(del.sectionLevel)
|
||
font.strikeout: del.model.isDone ?? false
|
||
elide: Text.ElideRight
|
||
Layout.fillWidth: true
|
||
textFormat: Text.StyledText
|
||
onLinkActivated: (link) => outline.openLink(link)
|
||
|
||
HoverHandler {
|
||
enabled: parent.hoveredLink !== ""
|
||
cursorShape: Qt.PointingHandCursor
|
||
}
|
||
}
|
||
|
||
// Scheduled timestamp. Click to edit via the date picker.
|
||
Label {
|
||
visible: (del.model.scheduled ?? "") !== ""
|
||
text: "⏱ " + (del.model.scheduled ?? "")
|
||
color: theme.timestampColor
|
||
font.pointSize: outline.baseFontSize - 1
|
||
MouseArea {
|
||
anchors.fill: parent
|
||
hoverEnabled: true
|
||
cursorShape: Qt.PointingHandCursor
|
||
onClicked: datePicker.open2(del.index, "SCHEDULED",
|
||
del.model.scheduled ?? "")
|
||
}
|
||
}
|
||
|
||
// Deadline timestamp. Click to edit via the date picker.
|
||
Label {
|
||
visible: (del.model.deadline ?? "") !== ""
|
||
text: "⚑ " + (del.model.deadline ?? "")
|
||
color: theme.deadlineColor
|
||
font.pointSize: outline.baseFontSize - 1
|
||
MouseArea {
|
||
anchors.fill: parent
|
||
hoverEnabled: true
|
||
cursorShape: Qt.PointingHandCursor
|
||
onClicked: datePicker.open2(del.index, "DEADLINE",
|
||
del.model.deadline ?? "")
|
||
}
|
||
}
|
||
|
||
// Calendar button: open the date picker to add or change a
|
||
// SCHEDULED/DEADLINE timestamp. Prefills from whichever field
|
||
// is already set (SCHEDULED preferred), else today.
|
||
Label {
|
||
text: "📅"
|
||
font.pointSize: outline.baseFontSize - 1
|
||
opacity: calMouse.containsMouse ? 1.0 : 0.45
|
||
MouseArea {
|
||
id: calMouse
|
||
anchors.fill: parent
|
||
hoverEnabled: true
|
||
cursorShape: Qt.PointingHandCursor
|
||
onClicked: {
|
||
var hasDeadline = (del.model.deadline ?? "") !== ""
|
||
var hasScheduled = (del.model.scheduled ?? "") !== ""
|
||
if (hasDeadline && !hasScheduled)
|
||
datePicker.open2(del.index, "DEADLINE", del.model.deadline)
|
||
else
|
||
datePicker.open2(del.index, "SCHEDULED",
|
||
del.model.scheduled ?? "")
|
||
}
|
||
}
|
||
}
|
||
|
||
// Tags. In display mode, each tag is a pill and clicking any
|
||
// pill (or the "+" affordance) opens an inline editor. In edit
|
||
// mode, a text field holds the space/colon/comma-separated tags;
|
||
// Enter or focus-loss commits via one 9P write, Escape cancels.
|
||
RowLayout {
|
||
spacing: 4
|
||
visible: !del.editingTags
|
||
|
||
Repeater {
|
||
model: del.model.tags ?? []
|
||
delegate: Rectangle {
|
||
required property string modelData
|
||
radius: 3
|
||
color: tagMouse.containsMouse ? theme.highlightColor
|
||
: theme.alternateBaseColor
|
||
implicitWidth: tagLabel.implicitWidth + 8
|
||
implicitHeight: tagLabel.implicitHeight + 3
|
||
Label {
|
||
id: tagLabel
|
||
anchors.centerIn: parent
|
||
text: parent.modelData
|
||
color: theme.tagColor
|
||
font.pointSize: outline.baseFontSize - 2
|
||
}
|
||
MouseArea {
|
||
id: tagMouse
|
||
anchors.fill: parent
|
||
hoverEnabled: true
|
||
cursorShape: Qt.PointingHandCursor
|
||
onClicked: del.editingTags = true
|
||
}
|
||
}
|
||
}
|
||
|
||
// "+" affordance to add/edit tags (always available).
|
||
Label {
|
||
text: "+"
|
||
color: theme.placeholderTextColor
|
||
font.pointSize: outline.baseFontSize - 1
|
||
opacity: addTagMouse.containsMouse ? 1.0 : 0.5
|
||
MouseArea {
|
||
id: addTagMouse
|
||
anchors.fill: parent
|
||
hoverEnabled: true
|
||
cursorShape: Qt.PointingHandCursor
|
||
onClicked: del.editingTags = true
|
||
}
|
||
}
|
||
}
|
||
|
||
TextField {
|
||
id: tagsEditor
|
||
visible: del.editingTags
|
||
Layout.preferredWidth: outline.baseFontSize * 14
|
||
placeholderText: qsTr("tag1 tag2 …")
|
||
font.pointSize: outline.baseFontSize - 1
|
||
selectByMouse: true
|
||
|
||
// Prefill with the current tags whenever edit mode opens.
|
||
onVisibleChanged: {
|
||
if (visible) {
|
||
text = (del.model.tags ?? []).join(" ")
|
||
forceActiveFocus()
|
||
selectAll()
|
||
}
|
||
}
|
||
|
||
function commit() {
|
||
docModel.setTags(del.index, text.trim())
|
||
del.editingTags = false
|
||
}
|
||
|
||
onAccepted: commit()
|
||
// Commit when focus is lost (click elsewhere), unless the
|
||
// editor was already closed (e.g. via Escape).
|
||
onActiveFocusChanged: {
|
||
if (!activeFocus && del.editingTags)
|
||
commit()
|
||
}
|
||
Keys.onEscapePressed: del.editingTags = false
|
||
}
|
||
}
|
||
|
||
// Properties drawer (collapsible)
|
||
RowLayout {
|
||
visible: Object.keys(del.model.properties ?? {}).length > 0
|
||
Layout.fillWidth: true
|
||
spacing: 4
|
||
|
||
Label {
|
||
id: drawerToggle
|
||
text: del.drawerOpen ? "▼" : "▶"
|
||
color: theme.placeholderTextColor
|
||
font.pointSize: outline.baseFontSize - 2
|
||
|
||
MouseArea {
|
||
anchors.fill: parent
|
||
cursorShape: Qt.PointingHandCursor
|
||
onClicked: del.drawerOpen = !del.drawerOpen
|
||
}
|
||
}
|
||
|
||
Label {
|
||
text: ":PROPERTIES:"
|
||
color: theme.placeholderTextColor
|
||
font.pointSize: outline.baseFontSize - 1
|
||
font.family: "monospace"
|
||
|
||
MouseArea {
|
||
anchors.fill: parent
|
||
cursorShape: Qt.PointingHandCursor
|
||
onClicked: del.drawerOpen = !del.drawerOpen
|
||
}
|
||
}
|
||
}
|
||
|
||
// Properties content (when expanded)
|
||
ColumnLayout {
|
||
visible: del.drawerOpen && Object.keys(del.model.properties ?? {}).length > 0
|
||
Layout.fillWidth: true
|
||
Layout.leftMargin: 16
|
||
spacing: 2
|
||
|
||
Repeater {
|
||
model: Object.keys(del.model.properties ?? {})
|
||
delegate: RowLayout {
|
||
required property string modelData
|
||
spacing: 8
|
||
Label {
|
||
text: ":" + modelData + ":"
|
||
color: theme.linkColor
|
||
font.pointSize: outline.baseFontSize - 1
|
||
font.family: "monospace"
|
||
}
|
||
Label {
|
||
text: del.model.properties[modelData] ?? ""
|
||
color: theme.textColor
|
||
font.pointSize: outline.baseFontSize - 1
|
||
font.family: "monospace"
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// Body content (rendered segments, or an inline editor). The whole
|
||
// block is shown when there is body text or while editing (so an
|
||
// empty body can still be opened for editing via the ⤤ affordance).
|
||
ColumnLayout {
|
||
visible: del.sectionBody !== "" || del.editingBody
|
||
Layout.fillWidth: true
|
||
spacing: 4
|
||
|
||
// Edit affordance (shown on the display side). Opens the inline
|
||
// body editor seeded with the raw body text.
|
||
RowLayout {
|
||
Layout.fillWidth: true
|
||
visible: !del.editingBody
|
||
Item { Layout.fillWidth: true }
|
||
Label {
|
||
text: "⤤ edit"
|
||
color: theme.placeholderTextColor
|
||
font.pointSize: outline.baseFontSize - 2
|
||
opacity: editBodyMouse.containsMouse ? 1.0 : 0.4
|
||
MouseArea {
|
||
id: editBodyMouse
|
||
anchors.fill: parent
|
||
hoverEnabled: true
|
||
cursorShape: Qt.PointingHandCursor
|
||
onClicked: del.editingBody = true
|
||
}
|
||
}
|
||
}
|
||
|
||
Repeater {
|
||
id: bodyRepeater
|
||
// Hidden while editing: an empty model removes the rendered
|
||
// segments so the editor below takes their place.
|
||
model: del.editingBody ? [] : del.bodyParts
|
||
|
||
delegate: Loader {
|
||
required property var modelData
|
||
Layout.fillWidth: true
|
||
sourceComponent: modelData.type === "code" ? codeBlockComponent :
|
||
modelData.type === "table" ? tableComponent :
|
||
modelData.type === "list" ? listComponent : textBlockComponent
|
||
|
||
Component {
|
||
id: textBlockComponent
|
||
RowLayout {
|
||
spacing: outline.baseFontSize * 0.5
|
||
Rectangle {
|
||
width: outline.baseFontSize / 6
|
||
Layout.fillHeight: true
|
||
Layout.leftMargin: outline.baseFontSize / 6
|
||
color: theme.midColor
|
||
}
|
||
Label {
|
||
Layout.fillWidth: true
|
||
text: outline.renderInline(modelData.content)
|
||
color: theme.placeholderTextColor
|
||
linkColor: theme.linkColor
|
||
font.pointSize: outline.baseFontSize + 1
|
||
lineHeight: 1.3
|
||
wrapMode: Text.WordWrap
|
||
textFormat: Text.StyledText
|
||
onLinkActivated: (link) => outline.openLink(link)
|
||
|
||
HoverHandler {
|
||
enabled: parent.hoveredLink !== ""
|
||
cursorShape: Qt.PointingHandCursor
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// Plain / checkbox list rendering
|
||
Component {
|
||
id: listComponent
|
||
ColumnLayout {
|
||
spacing: outline.baseFontSize * 0.2
|
||
|
||
Repeater {
|
||
model: modelData.items ?? []
|
||
|
||
delegate: RowLayout {
|
||
required property var modelData
|
||
spacing: outline.baseFontSize * 0.4
|
||
// Indentation: 2 spaces of org indent ≈ one level.
|
||
Layout.leftMargin: outline.baseFontSize
|
||
+ (modelData.indent / 2) * outline.indentWidth
|
||
|
||
// Checkbox glyph, or a plain bullet when no checkbox.
|
||
Label {
|
||
id: cbGlyph
|
||
readonly property string cb: modelData.checkbox ?? ""
|
||
text: {
|
||
switch (cb) {
|
||
case "checked": return "☑"
|
||
case "partial": return "▣"
|
||
case "unchecked": return "☐"
|
||
default:
|
||
return (modelData.ordered ?? false) ? "•" : (modelData.bullet ?? "•")
|
||
}
|
||
}
|
||
color: cb === "checked" ? theme.doneColor
|
||
: cb === "partial" ? theme.waitingColor
|
||
: cb === "unchecked" ? theme.todoColor
|
||
: theme.placeholderTextColor
|
||
font.pointSize: outline.baseFontSize + 1
|
||
font.bold: cb !== ""
|
||
|
||
// Checkbox interaction: left-click toggles,
|
||
// mouse wheel cycles ( ) -> (-) -> (X).
|
||
MouseArea {
|
||
anchors.fill: parent
|
||
enabled: cbGlyph.cb !== ""
|
||
visible: enabled
|
||
cursorShape: Qt.PointingHandCursor
|
||
onClicked: docModel.toggleCheckbox(
|
||
del.index, modelData.checkboxOrdinal ?? -1)
|
||
onWheel: (wheel) => {
|
||
// Scroll up = forward, down = reverse.
|
||
let forward = wheel.angleDelta.y > 0
|
||
docModel.cycleCheckbox(
|
||
del.index,
|
||
modelData.checkboxOrdinal ?? -1,
|
||
forward)
|
||
wheel.accepted = true
|
||
}
|
||
}
|
||
}
|
||
|
||
// Item text; checked items are dimmed and struck through.
|
||
Label {
|
||
Layout.fillWidth: true
|
||
text: outline.renderInline(modelData.text ?? "")
|
||
color: (modelData.checkbox === "checked")
|
||
? theme.placeholderTextColor : theme.textColor
|
||
linkColor: theme.linkColor
|
||
font.pointSize: outline.baseFontSize + 1
|
||
font.strikeout: modelData.checkbox === "checked"
|
||
wrapMode: Text.WordWrap
|
||
textFormat: Text.StyledText
|
||
onLinkActivated: (link) => outline.openLink(link)
|
||
|
||
HoverHandler {
|
||
enabled: parent.hoveredLink !== ""
|
||
cursorShape: Qt.PointingHandCursor
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
Component {
|
||
id: codeBlockComponent
|
||
Rectangle {
|
||
id: codeBlock
|
||
color: theme.alternateBaseColor
|
||
radius: outline.baseFontSize / 3
|
||
implicitHeight: codeColumn.implicitHeight + outline.baseFontSize
|
||
|
||
// Last execution status for this block: "", "running", "ok", "error"
|
||
property string runState: ""
|
||
property int runStatus: 0
|
||
|
||
ColumnLayout {
|
||
id: codeColumn
|
||
anchors.fill: parent
|
||
anchors.margins: outline.baseFontSize * 0.5
|
||
spacing: outline.baseFontSize * 0.3
|
||
|
||
// Header row: language label + run control
|
||
RowLayout {
|
||
Layout.fillWidth: true
|
||
spacing: outline.baseFontSize * 0.5
|
||
|
||
// Language label
|
||
Label {
|
||
visible: (modelData.language ?? "") !== ""
|
||
text: modelData.language
|
||
color: theme.linkColor
|
||
font.pointSize: outline.baseFontSize - 2
|
||
font.bold: true
|
||
}
|
||
|
||
Item { Layout.fillWidth: true }
|
||
|
||
// Run status indicator
|
||
Label {
|
||
visible: codeBlock.runState !== ""
|
||
text: {
|
||
switch (codeBlock.runState) {
|
||
case "running": return "running…"
|
||
case "ok": return "✓ exit 0"
|
||
case "error": return "✗ exit " + codeBlock.runStatus
|
||
default: return ""
|
||
}
|
||
}
|
||
color: codeBlock.runState === "error"
|
||
? theme.cancelledColor
|
||
: (codeBlock.runState === "ok"
|
||
? theme.doneColor
|
||
: theme.placeholderTextColor)
|
||
font.pointSize: outline.baseFontSize - 2
|
||
}
|
||
|
||
// Run button — only for executable languages
|
||
Rectangle {
|
||
id: runButton
|
||
visible: docModel.isLanguageSupported(modelData.language ?? "")
|
||
radius: 3
|
||
implicitWidth: runLabel.implicitWidth + 14
|
||
implicitHeight: runLabel.implicitHeight + 6
|
||
color: runMouse.containsMouse ? theme.highlightColor : "transparent"
|
||
border.color: theme.linkColor
|
||
border.width: 1
|
||
|
||
Label {
|
||
id: runLabel
|
||
anchors.centerIn: parent
|
||
text: "▶ Run"
|
||
color: theme.linkColor
|
||
font.pointSize: outline.baseFontSize - 2
|
||
font.bold: true
|
||
}
|
||
|
||
MouseArea {
|
||
id: runMouse
|
||
anchors.fill: parent
|
||
hoverEnabled: true
|
||
cursorShape: Qt.PointingHandCursor
|
||
onClicked: {
|
||
codeBlock.runState = "running"
|
||
let out = docModel.executeBlock(del.index, modelData.codeIndex ?? 0)
|
||
// The report contains a "status: N" line per block.
|
||
let m = out.match(/status:\s*(\d+)/)
|
||
let hadError = /\berror:/.test(out)
|
||
if (m) {
|
||
codeBlock.runStatus = parseInt(m[1])
|
||
codeBlock.runState = codeBlock.runStatus === 0 ? "ok" : "error"
|
||
} else {
|
||
codeBlock.runStatus = hadError ? 1 : 0
|
||
codeBlock.runState = hadError ? "error" : "ok"
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// Code content
|
||
Label {
|
||
Layout.fillWidth: true
|
||
text: modelData.content
|
||
color: theme.textColor
|
||
font.family: "monospace"
|
||
font.pointSize: outline.baseFontSize
|
||
wrapMode: Text.WrapAnywhere
|
||
textFormat: Text.PlainText
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
Component {
|
||
id: tableComponent
|
||
Rectangle {
|
||
id: tableRect
|
||
color: theme.alternateBaseColor
|
||
radius: outline.baseFontSize / 3
|
||
implicitHeight: tableColumn.implicitHeight + outline.baseFontSize * 1.5
|
||
|
||
property var tableData: modelData
|
||
|
||
ColumnLayout {
|
||
id: tableColumn
|
||
anchors.left: parent.left
|
||
anchors.top: parent.top
|
||
anchors.margins: outline.baseFontSize * 0.75
|
||
spacing: outline.baseFontSize * 0.25
|
||
|
||
Repeater {
|
||
model: tableRect.tableData.rows ?? []
|
||
|
||
RowLayout {
|
||
required property var modelData
|
||
required property int index
|
||
property bool isHeader: index === 0 && (tableRect.tableData.hasHeader ?? false)
|
||
spacing: outline.baseFontSize * 1.5
|
||
|
||
Repeater {
|
||
model: modelData
|
||
|
||
Label {
|
||
required property string modelData
|
||
leftPadding: outline.baseFontSize * 0.5
|
||
rightPadding: outline.baseFontSize * 0.5
|
||
topPadding: outline.baseFontSize * 0.25
|
||
bottomPadding: outline.baseFontSize * 0.25
|
||
text: modelData
|
||
color: parent.isHeader ? theme.textColor : theme.placeholderTextColor
|
||
font.bold: parent.isHeader
|
||
font.pointSize: outline.baseFontSize
|
||
horizontalAlignment: Text.AlignLeft
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// Inline body editor. Replaces the rendered segments while
|
||
// editing. Seeded with the raw body text; a toolbar inserts a
|
||
// timestamp at the cursor; Save writes via setBody (one 9P
|
||
// write, server reparses), Cancel/Escape discards.
|
||
ColumnLayout {
|
||
id: bodyEditColumn
|
||
visible: del.editingBody
|
||
Layout.fillWidth: true
|
||
spacing: 4
|
||
|
||
function saveBody() {
|
||
docModel.setBody(del.index, bodyEditor.text)
|
||
del.editingBody = false
|
||
}
|
||
|
||
ScrollView {
|
||
Layout.fillWidth: true
|
||
Layout.preferredHeight: Math.min(
|
||
Math.max(bodyEditor.contentHeight + 16, outline.baseFontSize * 6),
|
||
outline.baseFontSize * 20)
|
||
clip: true
|
||
|
||
TextArea {
|
||
id: bodyEditor
|
||
wrapMode: TextArea.Wrap
|
||
font.family: "monospace"
|
||
font.pointSize: outline.baseFontSize
|
||
selectByMouse: true
|
||
background: Rectangle {
|
||
color: theme.baseColor
|
||
border.color: theme.midColor
|
||
border.width: 1
|
||
radius: 3
|
||
}
|
||
// Seed from the raw body when the editor opens.
|
||
onVisibleChanged: {
|
||
if (visible) {
|
||
text = docModel.bodyText(del.index)
|
||
forceActiveFocus()
|
||
cursorPosition = text.length
|
||
}
|
||
}
|
||
// Ctrl+Enter saves; Escape cancels.
|
||
Keys.onPressed: (ev) => {
|
||
if ((ev.key === Qt.Key_Return || ev.key === Qt.Key_Enter)
|
||
&& (ev.modifiers & Qt.ControlModifier)) {
|
||
bodyEditColumn.saveBody()
|
||
ev.accepted = true
|
||
} else if (ev.key === Qt.Key_Escape) {
|
||
del.editingBody = false
|
||
ev.accepted = true
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// Editor toolbar.
|
||
RowLayout {
|
||
Layout.fillWidth: true
|
||
spacing: 6
|
||
|
||
Button {
|
||
text: qsTr("Insert timestamp…")
|
||
onClicked: {
|
||
outline.insertTarget = bodyEditor
|
||
datePicker.openInsert()
|
||
}
|
||
}
|
||
Item { Layout.fillWidth: true }
|
||
Label {
|
||
text: qsTr("Ctrl+Enter to save, Esc to cancel")
|
||
color: theme.placeholderTextColor
|
||
font.pointSize: outline.baseFontSize - 3
|
||
}
|
||
Button {
|
||
text: qsTr("Cancel")
|
||
onClicked: del.editingBody = false
|
||
}
|
||
Button {
|
||
text: qsTr("Save")
|
||
highlighted: true
|
||
onClicked: bodyEditColumn.saveBody()
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// Empty state
|
||
Label {
|
||
anchors.centerIn: parent
|
||
visible: outline.count === 0
|
||
text: qsTr("No sections")
|
||
color: theme.placeholderTextColor
|
||
font.pointSize: outline.baseFontSize
|
||
}
|
||
}
|