Files
Flora/qml/panels/EditorPane.qml
T

341 lines
13 KiB
QML
Raw Normal View History

import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
// Панель одного документа: шапка (заголовок, режим, действия) + текст.
// Режимы: редактирование (TextArea) / просмотр (read-only TextArea).
Item {
id: pane
property int docIndex: -1
property bool editMode: true
property bool canOpenBeside: false // кнопка «открыть рядом» (заглавная панель)
property bool closable: false // кнопка «закрыть панель» (вторая панель)
property string editorContent: "" // явное содержимое для TextArea (не биндинг на функцию)
signal openBeside()
signal closePane()
function refreshHeader() {
const title = documentManager.titleAt(pane.docIndex)
headerTitle.text = title + (documentManager.isDirty(pane.docIndex) ? " •" : "")
}
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"))
}
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
text: pane.editorContent
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("Документ не выбран")
}
TextArea {
id: viewer
anchors.fill: parent
visible: !pane.editMode
textFormat: TextEdit.RichText
text: ""
readOnly: true
selectByMouse: true
wrapMode: TextEdit.Wrap
padding: 12
onLinkActivated: (href) => pane.handleViewLink(href)
placeholderText: qsTr("Документ не выбран")
}
}
}
// ---------- Просмотр (режим просмотра) ----------
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()
}
// Свежий заголовок при смене признака «изменено»
Connections {
target: documentManager
function onDirtyFlagChanged(i) {
if (i === pane.docIndex)
pane.refreshHeader()
}
}
// Просмотр обновляется при изменении контента
Connections {
target: documentManager
function onContentChanged(i) {
if (i === pane.docIndex) {
pane.editorContent = documentManager.contentAt(i)
if (!pane.editMode)
pane.renderView()
}
}
}
onDocIndexChanged: {
pane.refreshHeader()
pane.renderView()
loadTimer.start()
}
// Таймер для надёжной загрузки контента (обходит тайминг layout/QML)
Timer {
id: loadTimer
interval: 30
onTriggered: {
if (pane.docIndex >= 0) {
pane.editorContent = documentManager.contentAt(pane.docIndex)
if (pane.editMode)
editor.forceActiveFocus()
} else {
pane.editorContent = ""
}
}
}
onEditModeChanged: {
if (!pane.editMode)
pane.renderView()
}
Component.onCompleted: pane.refreshHeader()
}