Fix: полностью переписан EditorPane — простая TextArea anchors.fill: parent без ColumnLayout, без промежуточных контейнеров; шапка как overlay z:10; viewer с handleViewLink; refresh при смене файла/режима
This commit is contained in:
+68
-273
@@ -2,185 +2,23 @@ import QtQuick
|
|||||||
import QtQuick.Controls
|
import QtQuick.Controls
|
||||||
import QtQuick.Layouts
|
import QtQuick.Layouts
|
||||||
|
|
||||||
// qml-disable-aot
|
|
||||||
|
|
||||||
// Панель одного документа: шапка (заголовок, режим, действия) + текст.
|
|
||||||
// Режимы: редактирование (TextArea) / просмотр (read-only TextArea).
|
|
||||||
Item {
|
Item {
|
||||||
id: pane
|
id: pane
|
||||||
|
|
||||||
property int docIndex: -1
|
property int docIndex: -1
|
||||||
property bool editMode: true
|
property bool editMode: true
|
||||||
property bool canOpenBeside: false // кнопка «открыть рядом» (заглавная панель)
|
property bool canOpenBeside: false
|
||||||
property bool closable: false // кнопка «закрыть панель» (вторая панель)
|
property bool closable: false
|
||||||
property string editorContent: "" // явное содержимое для TextArea (не биндинг на функцию)
|
|
||||||
|
|
||||||
signal openBeside()
|
signal openBeside()
|
||||||
signal closePane()
|
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) {
|
function insertText(t) {
|
||||||
editor.insert(editor.cursorPosition, 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"))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------- TextArea (заполняет ВСЁ пространство) ----------
|
// Шапка файла — плавающий оверлей
|
||||||
TextArea {
|
|
||||||
id: editor
|
|
||||||
anchors.fill: parent
|
|
||||||
visible: pane.editMode
|
|
||||||
color: "black"
|
|
||||||
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("Документ не выбран")
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------- Шапка файла (плавающий оверлей сверху) ----------
|
|
||||||
Rectangle {
|
Rectangle {
|
||||||
id: headerBar
|
|
||||||
anchors.top: parent.top
|
anchors.top: parent.top
|
||||||
anchors.left: parent.left
|
anchors.left: parent.left
|
||||||
anchors.right: parent.right
|
anchors.right: parent.right
|
||||||
@@ -196,144 +34,101 @@ Item {
|
|||||||
spacing: 6
|
spacing: 6
|
||||||
|
|
||||||
Label {
|
Label {
|
||||||
id: headerTitle
|
|
||||||
Layout.fillWidth: true
|
Layout.fillWidth: true
|
||||||
elide: Text.ElideMiddle
|
elide: Text.ElideMiddle
|
||||||
color: "#444"
|
color: "#444"
|
||||||
text: ""
|
text: documentManager.titleAt(pane.docIndex)
|
||||||
}
|
}
|
||||||
|
|
||||||
ToolButton {
|
ToolButton {
|
||||||
text: "✎"
|
text: "✎"
|
||||||
checkable: true
|
checkable: true
|
||||||
checked: pane.editMode
|
checked: pane.editMode
|
||||||
ToolTip.text: qsTr("Редактирование")
|
onClicked: pane.editMode = true
|
||||||
onClicked: { pane.editMode = true; editBtn.checked = true; viewBtn.checked = false }
|
|
||||||
}
|
}
|
||||||
ToolButton {
|
ToolButton {
|
||||||
id: viewBtn
|
|
||||||
text: "👁"
|
text: "👁"
|
||||||
checkable: true
|
checkable: true
|
||||||
checked: !pane.editMode
|
checked: !pane.editMode
|
||||||
ToolTip.text: qsTr("Просмотр")
|
onClicked: pane.editMode = false
|
||||||
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.text: qsTr("Открыть рядом")
|
|
||||||
onClicked: pane.openBeside()
|
|
||||||
}
|
}
|
||||||
ToolButton {
|
ToolButton {
|
||||||
text: "\u2715"
|
text: "\u2715"
|
||||||
visible: pane.closable
|
visible: pane.closable
|
||||||
ToolTip.text: qsTr("Закрыть панель")
|
|
||||||
onClicked: pane.closePane()
|
onClicked: pane.closePane()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------- Функции ----------
|
// TextArea — заполняет ВСЁ пространство
|
||||||
function loadContent() {
|
TextArea {
|
||||||
if (pane.docIndex >= 0) {
|
id: editor
|
||||||
const c = documentManager.contentAt(pane.docIndex)
|
anchors.fill: parent
|
||||||
// Принудительное обновление TextArea: сначала очищаем, затем ставим контент
|
visible: pane.editMode
|
||||||
editor.text = ""
|
color: "black"
|
||||||
editor.text = c
|
font.family: "monospace"
|
||||||
editor.cursorPosition = c.length
|
font.pixelSize: 14
|
||||||
pane.editorContent = c
|
padding: 10
|
||||||
} else {
|
onTextEdited: documentManager.updateContent(pane.docIndex, text)
|
||||||
editor.text = ""
|
|
||||||
pane.editorContent = ""
|
|
||||||
}
|
|
||||||
}
|
|
||||||
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 {
|
TextArea {
|
||||||
target: documentManager
|
id: viewer
|
||||||
function onDirtyFlagChanged(i) {
|
anchors.fill: parent
|
||||||
if (i === pane.docIndex)
|
visible: !pane.editMode
|
||||||
pane.refreshHeader()
|
readOnly: true
|
||||||
}
|
color: "black"
|
||||||
}
|
font.family: "monospace"
|
||||||
// Просмотр обновляется при изменении контента
|
font.pixelSize: 14
|
||||||
Connections {
|
padding: 10
|
||||||
target: documentManager
|
onLinkActivated: (href) => {
|
||||||
function onActiveIndexChanged() {
|
if (href.startsWith("flora://task/")) {
|
||||||
pane.loadContent()
|
const line = Number(href.substring(14))
|
||||||
}
|
if (line > 0) {
|
||||||
function onContentChanged(i) {
|
const lines = editor.text.split("\n")
|
||||||
if (i === pane.docIndex) {
|
const ln = lines[line - 1]
|
||||||
pane.loadContent()
|
if (ln !== undefined) {
|
||||||
if (!pane.editMode)
|
if (/\[ \]/.test(ln)) lines[line - 1] = ln.replace("[ ]", "[x]")
|
||||||
pane.renderView()
|
else if (/\[x\]/.test(ln)) lines[line - 1] = ln.replace("[x]", "[ ]")
|
||||||
|
editor.text = lines.join("\n")
|
||||||
|
documentManager.updateContent(pane.docIndex, editor.text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (href.startsWith("flora://open/")) {
|
||||||
|
documentManager.openDocument(decodeURIComponent(href.substring(13)))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
onDocIndexChanged: {
|
// Загрузка контента при смене файла
|
||||||
pane.refreshHeader()
|
function refresh() {
|
||||||
pane.renderView()
|
const idx = pane.docIndex
|
||||||
pane.loadContent()
|
if (idx >= 0) {
|
||||||
loadTimer.start()
|
const c = documentManager.contentAt(idx)
|
||||||
|
editor.text = c
|
||||||
|
viewer.text = mdParser.render(c, fileStore.vault, filePathDir(documentManager.pathAt(idx)))
|
||||||
|
} else {
|
||||||
|
editor.text = ""
|
||||||
|
viewer.text = ""
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Timer {
|
function filePathDir(p) {
|
||||||
id: loadTimer
|
const i = p.lastIndexOf("/")
|
||||||
interval: 50
|
return i >= 0 ? p.substring(0, i) : p
|
||||||
onTriggered: pane.loadContent()
|
|
||||||
}
|
}
|
||||||
onEditModeChanged: {
|
|
||||||
if (!pane.editMode)
|
onDocIndexChanged: refresh()
|
||||||
pane.renderView()
|
onEditModeChanged: { if (!editMode) refresh() }
|
||||||
|
|
||||||
|
Connections {
|
||||||
|
target: documentManager
|
||||||
|
function onActiveIndexChanged() {
|
||||||
|
if (documentManager.activeIndex === pane.docIndex)
|
||||||
|
refresh()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Component.onCompleted: pane.refreshHeader()
|
|
||||||
}
|
Component.onCompleted: refresh()
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user