2026-08-17 01:14:00 +03:00
|
|
|
import QtQuick
|
|
|
|
|
import QtQuick.Controls
|
|
|
|
|
import QtQuick.Layouts
|
|
|
|
|
|
2026-08-17 17:29:23 +03:00
|
|
|
// qml-disable-aot
|
|
|
|
|
|
2026-08-17 01:14:00 +03:00
|
|
|
// Панель одного документа: шапка (заголовок, режим, действия) + текст.
|
|
|
|
|
// Режимы: редактирование (TextArea) / просмотр (read-only TextArea).
|
|
|
|
|
Item {
|
|
|
|
|
id: pane
|
|
|
|
|
|
|
|
|
|
property int docIndex: -1
|
|
|
|
|
property bool editMode: true
|
|
|
|
|
property bool canOpenBeside: false // кнопка «открыть рядом» (заглавная панель)
|
|
|
|
|
property bool closable: false // кнопка «закрыть панель» (вторая панель)
|
2026-08-17 16:04:46 +03:00
|
|
|
property string editorContent: "" // явное содержимое для TextArea (не биндинг на функцию)
|
2026-08-17 01:14:00 +03:00
|
|
|
|
|
|
|
|
signal openBeside()
|
|
|
|
|
signal closePane()
|
|
|
|
|
|
|
|
|
|
function refreshHeader() {
|
|
|
|
|
const title = documentManager.titleAt(pane.docIndex)
|
|
|
|
|
headerTitle.text = title + (documentManager.isDirty(pane.docIndex) ? " •" : "")
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-17 12:34:03 +03:00
|
|
|
readonly property bool hasFocus: editor.activeFocus || viewer.activeFocus
|
|
|
|
|
|
|
|
|
|
// ---------- Функции форматирования (используются FormatToolbar) ----------
|
|
|
|
|
function syncText() {
|
|
|
|
|
documentManager.updateContent(pane.docIndex, editor.text)
|
|
|
|
|
}
|
|
|
|
|
function lineStart(pos) {
|
|
|
|
|
const t = editor.text
|
|
|
|
|
let i = Math.min(Math.max(pos, 0), t.length)
|
|
|
|
|
while (i > 0 && t[i - 1] !== "\n") --i
|
|
|
|
|
return i
|
|
|
|
|
}
|
|
|
|
|
function lineEnd(pos) {
|
|
|
|
|
const t = editor.text
|
|
|
|
|
let i = Math.min(Math.max(pos, 0), t.length)
|
|
|
|
|
while (i < t.length && t[i] !== "\n") ++i
|
|
|
|
|
return i
|
|
|
|
|
}
|
|
|
|
|
function replaceRange(start, end, newText) {
|
|
|
|
|
editor.remove(start, end)
|
|
|
|
|
editor.insert(start, newText)
|
|
|
|
|
editor.cursorPosition = start + newText.length
|
|
|
|
|
syncText()
|
|
|
|
|
editor.forceActiveFocus()
|
|
|
|
|
}
|
|
|
|
|
// Обёртка выделения pre…post (жирный, курсив и т.п.)
|
|
|
|
|
function applyInline(pre, post) {
|
|
|
|
|
const s = editor.selectionStart, e = editor.selectionEnd
|
|
|
|
|
const sel = editor.selectedText
|
|
|
|
|
if (s === e) {
|
|
|
|
|
const cp = editor.cursorPosition
|
|
|
|
|
editor.insert(cp, pre + post)
|
|
|
|
|
editor.cursorPosition = cp + pre.length
|
|
|
|
|
} else {
|
|
|
|
|
const t = pre + sel + post
|
|
|
|
|
editor.remove(s, e)
|
|
|
|
|
editor.insert(s, t)
|
|
|
|
|
editor.cursorPosition = s + pre.length + sel.length
|
|
|
|
|
}
|
|
|
|
|
syncText()
|
|
|
|
|
editor.forceActiveFocus()
|
|
|
|
|
}
|
|
|
|
|
// Префикс к каждой строке выделения (списки, цитата)
|
|
|
|
|
function applyLinePrefix(prefix) {
|
|
|
|
|
let s = editor.selectionStart, e = editor.selectionEnd
|
|
|
|
|
if (s === e) { s = lineStart(editor.cursorPosition); e = lineEnd(editor.cursorPosition) }
|
|
|
|
|
else { s = lineStart(s); e = lineEnd(e) }
|
|
|
|
|
const lines = editor.text.substring(s, e).split("\n")
|
|
|
|
|
for (let i = 0; i < lines.length; ++i)
|
|
|
|
|
lines[i] = prefix + lines[i]
|
|
|
|
|
replaceRange(s, e, lines.join("\n"))
|
|
|
|
|
}
|
|
|
|
|
// Переключение задач: "- [ ]"/"- [x]" ⇄ "- "
|
|
|
|
|
function applyTaskToggle() {
|
|
|
|
|
let s = editor.selectionStart, e = editor.selectionEnd
|
|
|
|
|
if (s === e) { s = lineStart(editor.cursorPosition); e = lineEnd(editor.cursorPosition) }
|
|
|
|
|
else { s = lineStart(s); e = lineEnd(e) }
|
|
|
|
|
const lines = editor.text.substring(s, e).split("\n")
|
|
|
|
|
for (let i = 0; i < lines.length; ++i) {
|
|
|
|
|
const ln = lines[i]
|
|
|
|
|
if (/^[-*]\s+\[[ x]\]\s+/.test(ln))
|
|
|
|
|
lines[i] = ln.replace(/^([-*])\s+\[[ x]\]\s+/, "$1 ")
|
|
|
|
|
else
|
|
|
|
|
lines[i] = "- [ ] " + ln
|
|
|
|
|
}
|
|
|
|
|
replaceRange(s, e, lines.join("\n"))
|
|
|
|
|
}
|
|
|
|
|
// Заголовок уровня 1..6
|
|
|
|
|
function applyHeading(level) {
|
|
|
|
|
let s = editor.selectionStart, e = editor.selectionEnd
|
|
|
|
|
if (s === e) { s = lineStart(editor.cursorPosition); e = lineEnd(editor.cursorPosition) }
|
|
|
|
|
else { s = lineStart(s); e = lineEnd(e) }
|
|
|
|
|
const lines = editor.text.substring(s, e).split("\n")
|
|
|
|
|
for (let i = 0; i < lines.length; ++i)
|
|
|
|
|
lines[i] = "#".repeat(level) + " " + lines[i].replace(/^#+\s*/, "")
|
|
|
|
|
replaceRange(s, e, lines.join("\n"))
|
|
|
|
|
}
|
|
|
|
|
function applyColor(hex, isHighlight) {
|
|
|
|
|
if (isHighlight)
|
|
|
|
|
applyInline("<mark style=\"background:" + hex + "\">", "</mark>")
|
|
|
|
|
else
|
|
|
|
|
applyInline("<span style=\"color:" + hex + "\">", "</span>")
|
|
|
|
|
}
|
|
|
|
|
function applySubSup(kind) {
|
|
|
|
|
applyInline(kind === "sup" ? "<sup>" : "<sub>", kind === "sup" ? "</sup>" : "</sub>")
|
|
|
|
|
}
|
|
|
|
|
function insertText(t) {
|
|
|
|
|
editor.insert(editor.cursorPosition, t)
|
|
|
|
|
syncText()
|
|
|
|
|
editor.forceActiveFocus()
|
|
|
|
|
}
|
|
|
|
|
// Обтекание картинок/таблиц директивой ![[...|float:left|width:40%]]
|
|
|
|
|
function applyFloatWrap() {
|
|
|
|
|
const s = editor.selectionStart, e = editor.selectionEnd
|
|
|
|
|
const sel = editor.selectedText.trim()
|
|
|
|
|
if (sel.length > 0) {
|
|
|
|
|
let inner = sel
|
|
|
|
|
if (/^!\[\[.+\]\]$/.test(sel))
|
|
|
|
|
inner = sel.slice(3, -2)
|
|
|
|
|
const text = "![[" + inner
|
|
|
|
|
+ (inner.includes("|") ? "" : "|float:left|width:40%") + "]]"
|
|
|
|
|
editor.remove(s, e)
|
|
|
|
|
editor.insert(s, text)
|
|
|
|
|
editor.cursorPosition = s + text.length
|
|
|
|
|
} else {
|
|
|
|
|
const t = "![[image.png|float:left|width:40%]]"
|
|
|
|
|
editor.insert(editor.cursorPosition, t)
|
|
|
|
|
editor.cursorPosition += t.length
|
|
|
|
|
}
|
|
|
|
|
syncText()
|
|
|
|
|
editor.forceActiveFocus()
|
|
|
|
|
}
|
|
|
|
|
// Выноска: > [!TYPE] + строки выделения
|
|
|
|
|
function applyCallout(type) {
|
|
|
|
|
let s = editor.selectionStart, e = editor.selectionEnd
|
|
|
|
|
if (s === e) { s = lineStart(editor.cursorPosition); e = lineEnd(editor.cursorPosition) }
|
|
|
|
|
else { s = lineStart(s); e = lineEnd(e) }
|
|
|
|
|
const lines = editor.text.substring(s, e).split("\n").filter(l => l.trim().length > 0)
|
|
|
|
|
const out = ["> [!NOTE]".replace("NOTE", type)]
|
|
|
|
|
if (lines.length > 0)
|
|
|
|
|
for (const l of lines) out.push("> " + l)
|
|
|
|
|
else
|
|
|
|
|
out.push("> Текст выноски")
|
|
|
|
|
replaceRange(s, e, out.join("\n"))
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-17 01:14:00 +03:00
|
|
|
ColumnLayout {
|
|
|
|
|
anchors.fill: parent
|
|
|
|
|
spacing: 0
|
|
|
|
|
|
|
|
|
|
Rectangle {
|
|
|
|
|
Layout.fillWidth: true
|
|
|
|
|
implicitHeight: 30
|
|
|
|
|
color: "whitesmoke"
|
|
|
|
|
|
|
|
|
|
RowLayout {
|
|
|
|
|
anchors.fill: parent
|
|
|
|
|
anchors.leftMargin: 8
|
|
|
|
|
anchors.rightMargin: 4
|
|
|
|
|
spacing: 6
|
|
|
|
|
|
|
|
|
|
Label {
|
|
|
|
|
id: headerTitle
|
|
|
|
|
Layout.fillWidth: true
|
|
|
|
|
elide: Text.ElideMiddle
|
|
|
|
|
color: "#444"
|
|
|
|
|
text: ""
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Переключатель режимов
|
|
|
|
|
ToolButton {
|
|
|
|
|
text: "✎"
|
|
|
|
|
checkable: true
|
|
|
|
|
checked: pane.editMode
|
|
|
|
|
ToolTip.visible: hovered
|
|
|
|
|
ToolTip.text: qsTr("Режим редактирования (Ctrl+E)")
|
|
|
|
|
onClicked: { pane.editMode = true; editBtn.checked = true; viewBtn.checked = false }
|
|
|
|
|
}
|
|
|
|
|
ToolButton {
|
|
|
|
|
id: viewBtn
|
|
|
|
|
text: "👁"
|
|
|
|
|
checkable: true
|
|
|
|
|
checked: !pane.editMode
|
|
|
|
|
ToolTip.visible: hovered
|
|
|
|
|
ToolTip.text: qsTr("Режим просмотра (Ctrl+E)")
|
|
|
|
|
onClicked: { pane.editMode = false; editBtn.checked = false; viewBtn.checked = true }
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
ToolButton {
|
|
|
|
|
id: editBtn
|
|
|
|
|
visible: false
|
|
|
|
|
checkable: true
|
|
|
|
|
checked: true
|
|
|
|
|
onClicked: { pane.editMode = true; viewBtn.checked = false }
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
ToolButton {
|
|
|
|
|
text: "\u25B8\u25CB\u25C0" // «открыть рядом» (две панели)
|
|
|
|
|
visible: pane.canOpenBeside && !documentManager.sideBySide
|
|
|
|
|
ToolTip.visible: hovered
|
|
|
|
|
ToolTip.text: qsTr("Открыть рядом (две панели)")
|
|
|
|
|
onClicked: pane.openBeside()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
ToolButton {
|
|
|
|
|
text: "\u2715"
|
|
|
|
|
visible: pane.closable
|
|
|
|
|
ToolTip.visible: hovered
|
|
|
|
|
ToolTip.text: qsTr("Закрыть вторую панель")
|
|
|
|
|
onClicked: pane.closePane()
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Rectangle {
|
|
|
|
|
Layout.fillWidth: true
|
|
|
|
|
Layout.fillHeight: true
|
|
|
|
|
color: "white"
|
|
|
|
|
|
|
|
|
|
TextArea {
|
|
|
|
|
id: editor
|
|
|
|
|
anchors.fill: parent
|
|
|
|
|
visible: pane.editMode
|
2026-08-17 17:29:23 +03:00
|
|
|
color: "black"
|
2026-08-17 01:14:00 +03:00
|
|
|
onTextEdited: documentManager.updateContent(pane.docIndex, text)
|
|
|
|
|
selectByMouse: true
|
|
|
|
|
font.family: "Menlo, Consolas, 'DejaVu Sans Mono', monospace"
|
|
|
|
|
font.pixelSize: 14
|
|
|
|
|
wrapMode: TextEdit.NoWrap
|
|
|
|
|
padding: 10
|
|
|
|
|
placeholderText: qsTr("Документ не выбран")
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-17 16:38:22 +03:00
|
|
|
// Отладочный индикатор (временно)
|
|
|
|
|
Label {
|
|
|
|
|
visible: true
|
|
|
|
|
anchors.bottom: parent.bottom
|
|
|
|
|
anchors.left: parent.left
|
|
|
|
|
anchors.margins: 4
|
|
|
|
|
font.pixelSize: 10
|
|
|
|
|
color: "#999"
|
|
|
|
|
text: "docIndex=" + pane.docIndex + " editMode=" + pane.editMode
|
|
|
|
|
+ " contentLen=" + editor.text.length
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-17 01:14:00 +03:00
|
|
|
TextArea {
|
|
|
|
|
id: viewer
|
|
|
|
|
anchors.fill: parent
|
|
|
|
|
visible: !pane.editMode
|
2026-08-17 13:05:19 +03:00
|
|
|
textFormat: TextEdit.RichText
|
|
|
|
|
text: ""
|
2026-08-17 01:14:00 +03:00
|
|
|
readOnly: true
|
|
|
|
|
selectByMouse: true
|
|
|
|
|
wrapMode: TextEdit.Wrap
|
|
|
|
|
padding: 12
|
2026-08-17 13:05:19 +03:00
|
|
|
onLinkActivated: (href) => pane.handleViewLink(href)
|
2026-08-17 01:14:00 +03:00
|
|
|
placeholderText: qsTr("Документ не выбран")
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-17 13:05:19 +03:00
|
|
|
// ---------- Просмотр (режим просмотра) ----------
|
2026-08-17 16:24:28 +03:00
|
|
|
function loadContent() {
|
2026-08-17 16:38:22 +03:00
|
|
|
console.log("loadContent called: docIndex=" + pane.docIndex + " editMode=" + pane.editMode)
|
2026-08-17 16:24:28 +03:00
|
|
|
if (pane.docIndex >= 0) {
|
|
|
|
|
const c = documentManager.contentAt(pane.docIndex)
|
2026-08-17 16:38:22 +03:00
|
|
|
console.log("loadContent content=" + c.substring(0, 50) + "...")
|
2026-08-17 16:24:28 +03:00
|
|
|
editor.text = c
|
|
|
|
|
pane.editorContent = c
|
2026-08-17 16:38:22 +03:00
|
|
|
console.log("loadContent editor.text.length=" + editor.text.length)
|
2026-08-17 16:24:28 +03:00
|
|
|
} else {
|
|
|
|
|
editor.text = ""
|
|
|
|
|
pane.editorContent = ""
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-08-17 13:05:19 +03:00
|
|
|
function pathDir(p) {
|
|
|
|
|
const idx = p.lastIndexOf("/")
|
|
|
|
|
return idx >= 0 ? p.substring(0, idx) : p
|
|
|
|
|
}
|
|
|
|
|
function renderView() {
|
|
|
|
|
if (pane.editMode || pane.docIndex < 0)
|
|
|
|
|
return
|
|
|
|
|
viewer.text = mdParser.render(documentManager.contentAt(pane.docIndex),
|
|
|
|
|
fileStore.vault,
|
|
|
|
|
pathDir(documentManager.pathAt(pane.docIndex)))
|
|
|
|
|
}
|
|
|
|
|
function handleViewLink(href) {
|
|
|
|
|
if (href.startsWith("flora://task/")) {
|
|
|
|
|
const line = Number(href.substring("flora://task/".length))
|
|
|
|
|
if (line > 0) pane.toggleTaskLine(line)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
if (href.startsWith("flora://open/")) {
|
|
|
|
|
const path = decodeURIComponent(href.substring("flora://open/".length))
|
|
|
|
|
if (path.length > 0) documentManager.openDocument(path)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
if (href.startsWith("flora://wiki/")) {
|
|
|
|
|
// Фаза 8: создание через базу связей; пока — статус.
|
|
|
|
|
console.log("wiki:", decodeURIComponent(href))
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
console.log("внешняя ссылка:", href)
|
|
|
|
|
}
|
|
|
|
|
function toggleTaskLine(line) {
|
|
|
|
|
const text = documentManager.contentAt(pane.docIndex)
|
|
|
|
|
const lines = text.split("\n")
|
|
|
|
|
const ln = lines[line - 1]
|
|
|
|
|
if (ln === undefined) return
|
|
|
|
|
if (/\[ \]/.test(ln))
|
|
|
|
|
lines[line - 1] = ln.replace("[ ]", "[x]")
|
|
|
|
|
else if (/\[x\]/.test(ln))
|
|
|
|
|
lines[line - 1] = ln.replace("[x]", "[ ]")
|
|
|
|
|
documentManager.updateContent(pane.docIndex, lines.join("\n"))
|
|
|
|
|
pane.renderView()
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-17 01:14:00 +03:00
|
|
|
// Свежий заголовок при смене признака «изменено»
|
|
|
|
|
Connections {
|
|
|
|
|
target: documentManager
|
|
|
|
|
function onDirtyFlagChanged(i) {
|
|
|
|
|
if (i === pane.docIndex)
|
|
|
|
|
pane.refreshHeader()
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-08-17 16:04:46 +03:00
|
|
|
// Просмотр обновляется при изменении контента
|
2026-08-17 01:14:00 +03:00
|
|
|
Connections {
|
|
|
|
|
target: documentManager
|
2026-08-17 16:24:28 +03:00
|
|
|
function onActiveIndexChanged() {
|
|
|
|
|
pane.loadContent()
|
|
|
|
|
}
|
2026-08-17 01:14:00 +03:00
|
|
|
function onContentChanged(i) {
|
2026-08-17 16:04:46 +03:00
|
|
|
if (i === pane.docIndex) {
|
2026-08-17 16:24:28 +03:00
|
|
|
pane.loadContent()
|
2026-08-17 16:04:46 +03:00
|
|
|
if (!pane.editMode)
|
|
|
|
|
pane.renderView()
|
|
|
|
|
}
|
2026-08-17 01:14:00 +03:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-17 13:05:19 +03:00
|
|
|
onDocIndexChanged: {
|
|
|
|
|
pane.refreshHeader()
|
|
|
|
|
pane.renderView()
|
2026-08-17 16:24:28 +03:00
|
|
|
pane.loadContent()
|
2026-08-17 16:15:39 +03:00
|
|
|
loadTimer.start()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Timer {
|
|
|
|
|
id: loadTimer
|
2026-08-17 16:24:28 +03:00
|
|
|
interval: 50
|
|
|
|
|
onTriggered: pane.loadContent()
|
2026-08-17 13:05:19 +03:00
|
|
|
}
|
|
|
|
|
onEditModeChanged: {
|
|
|
|
|
if (!pane.editMode)
|
|
|
|
|
pane.renderView()
|
|
|
|
|
}
|
2026-08-17 01:14:00 +03:00
|
|
|
Component.onCompleted: pane.refreshHeader()
|
|
|
|
|
}
|