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.Layouts
|
||||
|
||||
// qml-disable-aot
|
||||
|
||||
// Панель одного документа: шапка (заголовок, режим, действия) + текст.
|
||||
// Режимы: редактирование (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 (не биндинг на функцию)
|
||||
property bool canOpenBeside: false
|
||||
property bool closable: false
|
||||
|
||||
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"))
|
||||
}
|
||||
|
||||
// ---------- 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 {
|
||||
id: headerBar
|
||||
anchors.top: parent.top
|
||||
anchors.left: parent.left
|
||||
anchors.right: parent.right
|
||||
@@ -196,144 +34,101 @@ Item {
|
||||
spacing: 6
|
||||
|
||||
Label {
|
||||
id: headerTitle
|
||||
Layout.fillWidth: true
|
||||
elide: Text.ElideMiddle
|
||||
color: "#444"
|
||||
text: ""
|
||||
text: documentManager.titleAt(pane.docIndex)
|
||||
}
|
||||
|
||||
ToolButton {
|
||||
text: "✎"
|
||||
checkable: true
|
||||
checked: pane.editMode
|
||||
ToolTip.text: qsTr("Редактирование")
|
||||
onClicked: { pane.editMode = true; editBtn.checked = true; viewBtn.checked = false }
|
||||
onClicked: pane.editMode = true
|
||||
}
|
||||
ToolButton {
|
||||
id: viewBtn
|
||||
text: "👁"
|
||||
checkable: true
|
||||
checked: !pane.editMode
|
||||
ToolTip.text: qsTr("Просмотр")
|
||||
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()
|
||||
onClicked: pane.editMode = false
|
||||
}
|
||||
ToolButton {
|
||||
text: "\u2715"
|
||||
visible: pane.closable
|
||||
ToolTip.text: qsTr("Закрыть панель")
|
||||
onClicked: pane.closePane()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- Функции ----------
|
||||
function loadContent() {
|
||||
if (pane.docIndex >= 0) {
|
||||
const c = documentManager.contentAt(pane.docIndex)
|
||||
// Принудительное обновление TextArea: сначала очищаем, затем ставим контент
|
||||
editor.text = ""
|
||||
editor.text = c
|
||||
editor.cursorPosition = c.length
|
||||
pane.editorContent = c
|
||||
} else {
|
||||
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()
|
||||
// TextArea — заполняет ВСЁ пространство
|
||||
TextArea {
|
||||
id: editor
|
||||
anchors.fill: parent
|
||||
visible: pane.editMode
|
||||
color: "black"
|
||||
font.family: "monospace"
|
||||
font.pixelSize: 14
|
||||
padding: 10
|
||||
onTextEdited: documentManager.updateContent(pane.docIndex, text)
|
||||
}
|
||||
|
||||
// Свежий заголовок при смене признака «изменено»
|
||||
Connections {
|
||||
target: documentManager
|
||||
function onDirtyFlagChanged(i) {
|
||||
if (i === pane.docIndex)
|
||||
pane.refreshHeader()
|
||||
}
|
||||
}
|
||||
// Просмотр обновляется при изменении контента
|
||||
Connections {
|
||||
target: documentManager
|
||||
function onActiveIndexChanged() {
|
||||
pane.loadContent()
|
||||
}
|
||||
function onContentChanged(i) {
|
||||
if (i === pane.docIndex) {
|
||||
pane.loadContent()
|
||||
if (!pane.editMode)
|
||||
pane.renderView()
|
||||
// Просмотр
|
||||
TextArea {
|
||||
id: viewer
|
||||
anchors.fill: parent
|
||||
visible: !pane.editMode
|
||||
readOnly: true
|
||||
color: "black"
|
||||
font.family: "monospace"
|
||||
font.pixelSize: 14
|
||||
padding: 10
|
||||
onLinkActivated: (href) => {
|
||||
if (href.startsWith("flora://task/")) {
|
||||
const line = Number(href.substring(14))
|
||||
if (line > 0) {
|
||||
const lines = editor.text.split("\n")
|
||||
const ln = lines[line - 1]
|
||||
if (ln !== undefined) {
|
||||
if (/\[ \]/.test(ln)) lines[line - 1] = ln.replace("[ ]", "[x]")
|
||||
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()
|
||||
pane.renderView()
|
||||
pane.loadContent()
|
||||
loadTimer.start()
|
||||
// Загрузка контента при смене файла
|
||||
function refresh() {
|
||||
const idx = pane.docIndex
|
||||
if (idx >= 0) {
|
||||
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 {
|
||||
id: loadTimer
|
||||
interval: 50
|
||||
onTriggered: pane.loadContent()
|
||||
function filePathDir(p) {
|
||||
const i = p.lastIndexOf("/")
|
||||
return i >= 0 ? p.substring(0, i) : p
|
||||
}
|
||||
onEditModeChanged: {
|
||||
if (!pane.editMode)
|
||||
pane.renderView()
|
||||
|
||||
onDocIndexChanged: refresh()
|
||||
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