Фаза 7: правая панель — VaultIndex (теги/[[вики]]/md-ссылки, граф), MonthCalendar (заметки дня), RightPanel (ссылки+теги, чипы, попап файлов), GraphView (Canvas, drag, клик-открытие); dailyNotePath; тест vaultindex (16/16); self-contained Flora.app
This commit is contained in:
@@ -0,0 +1,115 @@
|
||||
import QtQuick
|
||||
|
||||
// Небольшое полотно графа связей: узлы-файлы (синие) и теги (зелёные),
|
||||
// рёбра — связи; узлы можно перетаскивать, клик по файлу открывает его.
|
||||
Canvas {
|
||||
id: g
|
||||
|
||||
property var nodes: [] // [{key,name,type}]
|
||||
property var edges: [] // [{from,to}]
|
||||
signal fileClicked(string path)
|
||||
|
||||
property var pos: ({}) // key -> {x,y}
|
||||
property string dragKey: ""
|
||||
|
||||
function nodeForKey(key) {
|
||||
for (let i = 0; i < nodes.length; ++i)
|
||||
if (nodes[i].key === key) return nodes[i]
|
||||
return null
|
||||
}
|
||||
|
||||
function relayout() {
|
||||
pos = {}
|
||||
const n = nodes.length
|
||||
if (n === 0) return
|
||||
const cx = width / 2, cy = height / 2
|
||||
const rad = Math.min(width, height) / 2 - 30
|
||||
for (let i = 0; i < n; ++i) {
|
||||
const a = (i / n) * 2 * Math.PI - Math.PI / 2
|
||||
pos[nodes[i].key] = { x: cx + rad * Math.cos(a), y: cy + rad * Math.sin(a) }
|
||||
}
|
||||
requestPaint()
|
||||
}
|
||||
|
||||
onNodesChanged: relayout()
|
||||
onWidthChanged: relayout()
|
||||
onHeightChanged: relayout()
|
||||
|
||||
onPaint: {
|
||||
const ctx = getContext("2d")
|
||||
ctx.clearRect(0, 0, width, height)
|
||||
if (nodes.length === 0) return
|
||||
|
||||
// рёбра
|
||||
ctx.lineWidth = 1
|
||||
ctx.strokeStyle = "#b9c6d6"
|
||||
for (let i = 0; i < edges.length; ++i) {
|
||||
const f = pos[edges[i].from]
|
||||
const t = pos[edges[i].to]
|
||||
if (!f || !t) continue
|
||||
ctx.beginPath()
|
||||
ctx.moveTo(f.x, f.y)
|
||||
ctx.lineTo(t.x, t.y)
|
||||
ctx.stroke()
|
||||
}
|
||||
|
||||
// узлы
|
||||
ctx.textAlign = "center"
|
||||
for (let i = 0; i < nodes.length; ++i) {
|
||||
const nd = nodes[i]
|
||||
const p = pos[nd.key]
|
||||
if (!p) continue
|
||||
const isFile = nd.type === "file"
|
||||
const r = isFile ? 6 : 5
|
||||
ctx.beginPath()
|
||||
ctx.arc(p.x, p.y, r, 0, 2 * Math.PI)
|
||||
ctx.fillStyle = isFile ? (nd.key === g.dragKey ? "#1a6ce0" : "#3f7fd0")
|
||||
: "#3aa06a"
|
||||
ctx.fill()
|
||||
ctx.strokeStyle = "#ffffff"
|
||||
ctx.lineWidth = 1
|
||||
ctx.stroke()
|
||||
ctx.fillStyle = "#5a6a7d"
|
||||
ctx.font = "9px sans-serif"
|
||||
const label = isFile ? nd.name : nd.name
|
||||
const txt = label.length > 14 ? label.substring(0, 13) + "…" : label
|
||||
ctx.fillText(txt, p.x, p.y + r + 10)
|
||||
}
|
||||
}
|
||||
|
||||
MouseArea {
|
||||
anchors.fill: parent
|
||||
onPressed: (m) => {
|
||||
dragKey = ""
|
||||
for (const key in g.pos) {
|
||||
const p = g.pos[key]
|
||||
const dx = m.x - p.x, dy = m.y - p.y
|
||||
if (dx * dx + dy * dy <= 144) { // радиус захвата 12px
|
||||
dragKey = key
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
onPositionChanged: (m) => {
|
||||
if (dragKey.length > 0 && g.pos[dragKey]) {
|
||||
g.pos[dragKey] = { x: m.x, y: m.y }
|
||||
g.requestPaint()
|
||||
}
|
||||
}
|
||||
onReleased: (m) => {
|
||||
if (dragKey.length === 0) {
|
||||
for (const key in g.pos) {
|
||||
const p = g.pos[key]
|
||||
const dx = m.x - p.x, dy = m.y - p.y
|
||||
if (dx * dx + dy * dy <= 100) {
|
||||
const nd = g.nodeForKey(key)
|
||||
if (nd && nd.type === "file")
|
||||
g.fileClicked(nd.key)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
dragKey = ""
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
import QtQuick
|
||||
import QtQuick.Controls
|
||||
import QtQuick.Layouts
|
||||
|
||||
// Простой календарь: шапка (месяц/год, ←/→), сетка дней.
|
||||
// Клик по дню → onDaySelected("YYYY-MM-DD").
|
||||
Item {
|
||||
id: cal
|
||||
|
||||
signal daySelected(string dateStr)
|
||||
|
||||
property date currentMonth: new Date()
|
||||
readonly property int year: currentMonth.getFullYear()
|
||||
readonly property int month: currentMonth.getMonth() // 0..11
|
||||
|
||||
function fmtDate(d) {
|
||||
function pad(n) { return n < 10 ? "0" + n : "" + n }
|
||||
return d.getFullYear() + "-" + pad(d.getMonth() + 1) + "-" + pad(d.getDate())
|
||||
}
|
||||
|
||||
function gridModel() {
|
||||
const first = new Date(year, month, 1)
|
||||
const startDow = first.getDay() // 0=вс
|
||||
const daysInMonth = new Date(year, month + 1, 0).getDate()
|
||||
const list = []
|
||||
for (let i = 0; i < startDow; ++i)
|
||||
list.push(0)
|
||||
for (let d = 1; d <= daysInMonth; ++d)
|
||||
list.push(d)
|
||||
return list
|
||||
}
|
||||
|
||||
ColumnLayout {
|
||||
anchors.fill: parent
|
||||
spacing: 2
|
||||
|
||||
RowLayout {
|
||||
Layout.fillWidth: true
|
||||
ToolButton {
|
||||
text: "<"
|
||||
onClicked: cal.currentMonth = new Date(year, month - 1, 1)
|
||||
}
|
||||
Label {
|
||||
Layout.fillWidth: true
|
||||
horizontalAlignment: Text.AlignHCenter
|
||||
font.bold: true
|
||||
text: Qt.locale().monthName(month, Locale.LongFormat) + " " + year
|
||||
}
|
||||
ToolButton {
|
||||
text: ">"
|
||||
onClicked: cal.currentMonth = new Date(year, month + 1, 1)
|
||||
}
|
||||
}
|
||||
|
||||
RowLayout {
|
||||
Layout.fillWidth: true
|
||||
Repeater {
|
||||
model: ["Пн", "Вт", "Ср", "Чт", "Пт", "Сб", "Вс"]
|
||||
Label {
|
||||
Layout.fillWidth: true
|
||||
horizontalAlignment: Text.AlignHCenter
|
||||
font.pixelSize: 10
|
||||
color: "#888"
|
||||
text: modelData
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Grid {
|
||||
Layout.fillWidth: true
|
||||
Layout.fillHeight: true
|
||||
columns: 7
|
||||
rowSpacing: 1
|
||||
columnSpacing: 1
|
||||
|
||||
Repeater {
|
||||
model: cal.gridModel()
|
||||
delegate: Rectangle {
|
||||
required property int modelData
|
||||
width: cal.width / 7 - 2
|
||||
height: cal.height / 10 - 1
|
||||
radius: 3
|
||||
color: modelData > 0 && modelData === now.day ? "#dbeafe"
|
||||
: (modelData > 0 ? "#ffffff" : "transparent")
|
||||
|
||||
readonly property var now: {
|
||||
const t = new Date()
|
||||
return { day: t.getDate(), month: t.getMonth(), year: t.getFullYear() }
|
||||
}
|
||||
|
||||
Label {
|
||||
anchors.centerIn: parent
|
||||
visible: modelData > 0
|
||||
text: modelData
|
||||
font.pixelSize: 11
|
||||
color: modelData > 0 ? "#222" : "#ddd"
|
||||
}
|
||||
|
||||
MouseArea {
|
||||
anchors.fill: parent
|
||||
visible: modelData > 0
|
||||
onClicked: {
|
||||
const d = new Date(cal.year, cal.month, modelData)
|
||||
cal.daySelected(fmtDate(d))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+197
-14
@@ -2,36 +2,219 @@ import QtQuick
|
||||
import QtQuick.Controls
|
||||
import QtQuick.Layouts
|
||||
|
||||
// Правая панель: календарь + ссылки/теги + граф связей.
|
||||
// Фаза 0 — каркас; контент наполняется в Фазах 7 и 8.
|
||||
// Правая панель: календарь (заметки дня), «Ссылки и теги» активного файла,
|
||||
// граф связей (файлы + теги, рёбра по ссылкам).
|
||||
Item {
|
||||
id: root
|
||||
|
||||
function activePath() {
|
||||
return documentManager.pathAt(documentManager.activeIndex)
|
||||
}
|
||||
|
||||
function refreshLinksTags() {
|
||||
const p = root.activePath()
|
||||
linksModel.clear()
|
||||
if (p.length > 0) {
|
||||
const links = vaultIndex.linksOfFile(p)
|
||||
for (let i = 0; i < links.length; ++i) {
|
||||
linksModel.append({ text: links[i].text, path: links[i].path })
|
||||
}
|
||||
}
|
||||
tagsModel.clear()
|
||||
const tags = p.length > 0 ? vaultIndex.tagsOfFile(p) : []
|
||||
for (let i = 0; i < tags.length; ++i)
|
||||
tagsModel.append({ tag: tags[i] })
|
||||
}
|
||||
|
||||
Component.onCompleted: {
|
||||
if (fileStore.ready)
|
||||
vaultIndex.rebuild(fileStore.vault)
|
||||
}
|
||||
|
||||
Connections {
|
||||
target: fileStore
|
||||
function onVaultChanged() {
|
||||
if (fileStore.ready)
|
||||
vaultIndex.rebuild(fileStore.vault)
|
||||
}
|
||||
function onReadyChanged() {
|
||||
if (fileStore.ready)
|
||||
vaultIndex.rebuild(fileStore.vault)
|
||||
}
|
||||
}
|
||||
Connections {
|
||||
target: documentManager
|
||||
function onActiveIndexChanged() { root.refreshLinksTags() }
|
||||
function onOpenTabsChanged() { root.refreshLinksTags() }
|
||||
function onContentChanged(i) {
|
||||
if (i === documentManager.activeIndex) root.refreshLinksTags()
|
||||
}
|
||||
}
|
||||
Connections {
|
||||
target: vaultIndex
|
||||
function onIndexChanged() { root.refreshLinksTags() }
|
||||
}
|
||||
|
||||
ColumnLayout {
|
||||
anchors.fill: parent
|
||||
spacing: 0
|
||||
|
||||
// ---------- Календарь ----------
|
||||
Rectangle {
|
||||
Layout.fillWidth: true
|
||||
implicitHeight: 218
|
||||
color: "#fafafa"
|
||||
ColumnLayout {
|
||||
anchors.fill: parent
|
||||
spacing: 0
|
||||
Label {
|
||||
text: qsTr("Календарь")
|
||||
font.bold: true
|
||||
color: "#555"
|
||||
padding: 6
|
||||
}
|
||||
MonthCalendar {
|
||||
Layout.fillWidth: true
|
||||
Layout.fillHeight: true
|
||||
onDaySelected: (ds) => {
|
||||
const path = fileStore.dailyNotePath(ds)
|
||||
if (path.length > 0)
|
||||
documentManager.openDocument(path)
|
||||
}
|
||||
} }
|
||||
}
|
||||
|
||||
// ---------- Ссылки и теги активного файла ----------
|
||||
Rectangle {
|
||||
Layout.fillWidth: true
|
||||
Layout.fillHeight: true
|
||||
color: "#fafafa"
|
||||
Label {
|
||||
anchors.centerIn: parent
|
||||
text: qsTr("Календарь\n(Фаза 7)")
|
||||
horizontalAlignment: Text.AlignHCenter
|
||||
color: "#bbb"
|
||||
color: "#f4f4f4"
|
||||
ColumnLayout {
|
||||
anchors.fill: parent
|
||||
anchors.margins: 6
|
||||
spacing: 4
|
||||
|
||||
Label {
|
||||
text: qsTr("Ссылки и теги")
|
||||
font.bold: true
|
||||
color: "#555"
|
||||
}
|
||||
Label {
|
||||
text: root.activePath().length > 0
|
||||
? qsTr("Файл: %1").arg(root.baseName(activePath()))
|
||||
: qsTr("Нет активного файла")
|
||||
elide: Text.ElideMiddle
|
||||
color: "#888"
|
||||
Layout.fillWidth: true
|
||||
font.pixelSize: 11
|
||||
}
|
||||
|
||||
Label { text: qsTr("Ссылки"); color: "#666"; font.pixelSize: 11 }
|
||||
ListView {
|
||||
id: linksList
|
||||
Layout.fillWidth: true
|
||||
Layout.preferredHeight: Math.min(120, linksModel.count * 22)
|
||||
model: ListModel { id: linksModel }
|
||||
clip: true
|
||||
delegate: ItemDelegate {
|
||||
width: ListView.view.width
|
||||
height: 22
|
||||
text: model.text
|
||||
font.pixelSize: 12
|
||||
onClicked: documentManager.openDocument(model.path)
|
||||
}
|
||||
}
|
||||
Label {
|
||||
visible: linksModel.count === 0
|
||||
text: qsTr("— нет ссылок —")
|
||||
color: "#bbb"
|
||||
font.pixelSize: 11
|
||||
}
|
||||
|
||||
Label { text: qsTr("Теги"); color: "#666"; font.pixelSize: 11 }
|
||||
Flow {
|
||||
Layout.fillWidth: true
|
||||
readonly property var m: ListModel { id: tagsModel }
|
||||
Repeater {
|
||||
model: tagsModel
|
||||
delegate: TagChip {
|
||||
text: "#" + model.tag
|
||||
onClicked: {
|
||||
tagFilesModel.clear()
|
||||
const files = vaultIndex.filesWithTag(model.tag)
|
||||
for (let i = 0; i < files.length; ++i)
|
||||
tagFilesModel.append({ path: files[i], name: baseName(files[i]) })
|
||||
tagFilesTitle.text = qsTr("Файлы с тегом #%1 (%2)").arg(model.tag).arg(files.length)
|
||||
tagFilesPopup.open()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Item { Layout.fillHeight: true }
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- Граф связей ----------
|
||||
Rectangle {
|
||||
Layout.fillWidth: true
|
||||
Layout.fillHeight: true
|
||||
color: "#f5f5f5"
|
||||
Label {
|
||||
anchors.centerIn: parent
|
||||
text: qsTr("Граф связей\n(Фаза 7)")
|
||||
horizontalAlignment: Text.AlignHCenter
|
||||
color: "#bbb"
|
||||
color: "#eef1f5"
|
||||
ColumnLayout {
|
||||
anchors.fill: parent
|
||||
spacing: 0
|
||||
RowLayout {
|
||||
Layout.fillWidth: true
|
||||
Label {
|
||||
text: qsTr("Граф связей")
|
||||
font.bold: true
|
||||
color: "#555"
|
||||
padding: 6
|
||||
}
|
||||
Item { Layout.fillWidth: true }
|
||||
Button {
|
||||
text: qsTr("Обновить")
|
||||
font.pixelSize: 11
|
||||
implicitHeight: 22
|
||||
onClicked: vaultIndex.rebuild(fileStore.vault)
|
||||
}
|
||||
}
|
||||
GraphView {
|
||||
Layout.fillWidth: true
|
||||
Layout.fillHeight: true
|
||||
nodes: vaultIndex.nodes
|
||||
edges: vaultIndex.edges
|
||||
onFileClicked: (path) => documentManager.openDocument(path)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function baseName(p) {
|
||||
const idx = p.lastIndexOf("/")
|
||||
return idx >= 0 ? p.substring(idx + 1) : p
|
||||
}
|
||||
|
||||
Popup {
|
||||
id: tagFilesPopup
|
||||
modal: true
|
||||
anchors.centerIn: parent
|
||||
width: 300
|
||||
ColumnLayout {
|
||||
width: parent.width
|
||||
Label { id: tagFilesTitle; font.bold: true }
|
||||
ListView {
|
||||
Layout.preferredHeight: Math.min(220, contentHeight)
|
||||
Layout.fillWidth: true
|
||||
model: ListModel { id: tagFilesModel }
|
||||
clip: true
|
||||
delegate: ItemDelegate {
|
||||
width: ListView.view.width
|
||||
text: model.name
|
||||
onClicked: {
|
||||
documentManager.openDocument(model.path)
|
||||
tagFilesPopup.close()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import QtQuick
|
||||
import QtQuick.Controls
|
||||
|
||||
// Кликабельный «чип» тега.
|
||||
Item {
|
||||
id: chip
|
||||
|
||||
property string text: ""
|
||||
signal clicked()
|
||||
|
||||
implicitWidth: label.implicitWidth + 16
|
||||
implicitHeight: 20
|
||||
|
||||
Rectangle {
|
||||
anchors.fill: parent
|
||||
radius: height / 2
|
||||
color: mouse.containsMouse ? "#dbeafe" : "#e8eef7"
|
||||
border.color: "#b7c9e0"
|
||||
|
||||
Label {
|
||||
id: label
|
||||
anchors.centerIn: parent
|
||||
text: chip.text
|
||||
font.pixelSize: 10
|
||||
color: "#2b4b74"
|
||||
}
|
||||
|
||||
MouseArea {
|
||||
id: mouse
|
||||
anchors.fill: parent
|
||||
hoverEnabled: true
|
||||
onClicked: chip.clicked()
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user