Files
2026-09-22 13:14:06 +02:00

310 lines
12 KiB
JavaScript

import Clutter from 'gi://Clutter';
import GLib from 'gi://GLib';
import GObject from 'gi://GObject';
import Pango from 'gi://Pango';
import St from 'gi://St';
import {continueList, renderMarkdown} from './markdown.js';
import {contextMenu, multilineEntry, Popover, scrollBox, textButton} from './widgets.js';
const AUTOSAVE_SECONDS = 5;
export const NotesPanel = GObject.registerClass(
class NotesPanel extends St.BoxLayout {
_init(store) {
super._init({style_class: 'desktop-panel notes', reactive: true, x_expand: false, y_expand: true});
this._store = store;
this._expanded = new Set();
this._selected = null; // {type: 'note' | 'folder', path, name}
this._editing = false;
// Links: Ordnerbaum
const left = new St.BoxLayout({vertical: true, style_class: 'notes-tree-column', x_expand: false});
const [treeScroll, tree] = scrollBox('tree-scroll');
this._tree = tree;
left.add_child(treeScroll);
this.add_child(left);
// Rechts: Anzeige ohne Fokus, Editor mit Fokus. Klick in die Fläche startet den Editor.
const right = new St.BoxLayout({vertical: true, style_class: 'notes-content-column', x_expand: true});
this._noteTitle = new St.Label({style_class: 'panel-title'});
this._noteTitle.clutter_text.ellipsize = Pango.EllipsizeMode.END;
right.add_child(this._noteTitle);
const [viewScroll, viewBox] = scrollBox('note-scroll');
this._viewScroll = viewScroll;
this._content = new St.Label({style_class: 'note-content', x_expand: true});
this._content.clutter_text.line_wrap = true;
this._content.clutter_text.line_wrap_mode = Pango.WrapMode.WORD_CHAR;
this._content.clutter_text.ellipsize = Pango.EllipsizeMode.NONE;
this._content.clutter_text.use_markup = true;
viewBox.add_child(this._content);
viewScroll.connect('button-press-event', (_actor, event) => {
if (event.get_button() !== Clutter.BUTTON_PRIMARY)
return Clutter.EVENT_PROPAGATE;
this._startEdit();
return Clutter.EVENT_STOP;
});
right.add_child(viewScroll);
[this._editScroll, this._editor] = multilineEntry('', 'note-editor', 'note-editor-scroll');
this._editor.clutter_text.connect('text-changed', () => (this._dirty = true));
this._editor.clutter_text.connect('key-focus-out', () => this._stopEdit());
this._editor.clutter_text.connect('key-press-event', (text, event) => this._onEditorKey(text, event));
right.add_child(this._editScroll);
this.add_child(right);
// Rechtsklick irgendwo im Panel legt im Wurzelordner an. Zeilen und Popover fangen ihren Klick selbst ab.
this.connect('button-press-event', (_actor, event) => {
if (event.get_button() !== Clutter.BUTTON_SECONDARY)
return Clutter.EVENT_PROPAGATE;
this._createMenu(store.notesDir);
return Clutter.EVENT_STOP;
});
this.connect('destroy', () => this._stopAutosave());
// Änderungen vom Sync. Während des Tippens bleibt der Editor, wie er ist.
store.onChange(() => {
if (!this._editing)
this._refresh();
});
this._refresh();
}
_refresh() {
this._tree.destroy_all_children();
this._addChildren(this._store.tree(), 0);
this._showSelected();
}
_addChildren(folder, depth) {
for (const sub of folder.folders) {
const open = this._expanded.has(sub.path);
this._treeRow(sub.name, depth, 'folder', sub, () => {
if (open)
this._expanded.delete(sub.path);
else
this._expanded.add(sub.path);
this._select({type: 'folder', path: sub.path, name: sub.name});
});
if (open)
this._addChildren(sub, depth + 1);
}
for (const note of folder.notes)
this._treeRow(note.name, depth, 'note', note, () => this._select({type: 'note', path: note.path, name: note.name}));
}
// Links wählt aus, Mitte benennt um, rechts öffnet das Menü.
_treeRow(text, depth, type, node, onClick) {
const row = textButton(text, (_b, mouseButton) => {
if (mouseButton === 2)
this._rename(type, node);
else if (mouseButton === 3)
this._rowMenu(type, node);
else
onClick();
}, `tree-row ${type}`, type === 'folder' ? (this._expanded.has(node.path) ? 'pan-down-symbolic' : 'pan-end-symbolic') : null);
// Notizen ohne Symbol, aber bündig mit dem Ordnernamen (Pfeil 14px + Abstand 6px)
row.set_style(`padding-left: ${8 + depth * 16 + (type === 'note' ? 20 : 0)}px`);
if (this._selected?.path === node.path)
row.add_style_class_name('selected');
this._tree.add_child(row);
}
// Enter in einer Liste setzt den nächsten Punkt, Enter auf einem leeren Punkt beendet die Liste.
_onEditorKey(clutterText, event) {
const symbol = event.get_key_symbol();
if (symbol !== Clutter.KEY_Return && symbol !== Clutter.KEY_KP_Enter)
return Clutter.EVENT_PROPAGATE;
if (event.get_state() & (Clutter.ModifierType.CONTROL_MASK | Clutter.ModifierType.SHIFT_MASK))
return Clutter.EVENT_PROPAGATE;
const text = clutterText.get_text();
const pos = clutterText.cursor_position < 0 ? text.length : clutterText.cursor_position;
const lineStart = text.lastIndexOf('\n', pos - 1) + 1;
const next = continueList(text.slice(lineStart, pos));
if (next === null)
return Clutter.EVENT_PROPAGATE;
if (next === '')
clutterText.delete_text(lineStart, pos);
else
clutterText.insert_text(`\n${next}`, pos);
return Clutter.EVENT_STOP;
}
// Auswahl wechseln. Ein offener Editor wird vorher gespeichert.
_select(selected) {
this._stopEdit();
this._selected = selected;
this._refresh();
}
_showSelected() {
const selected = this._selected;
this._editScroll.hide();
this._viewScroll.show();
if (selected?.type === 'note') {
let text;
try {
text = this._store.readNote(selected.path);
} catch {
// Datei wurde außerhalb gelöscht
this._selected = null;
this._showSelected();
return;
}
this._noteTitle.text = selected.name;
// Erst leeren: Clutter behält sonst die zusammengeführten Textattribute des vorigen
// Inhalts für die Höhenberechnung, und eine Notiz mit Überschriften wird unten abgeschnitten.
this._content.clutter_text.set_markup('');
this._content.clutter_text.set_markup(renderMarkdown(text));
} else {
this._noteTitle.text = '';
this._content.text = '';
}
}
// ---- Editor direkt in der Fläche ----
// Mit Fokus steht der rohe Text im Editor, alle paar Sekunden und beim Fokusverlust wird gespeichert.
_startEdit() {
if (this._editing || this._selected?.type !== 'note')
return;
this._editing = true;
this._store.lockedNote = this._selected.path;
this._editor.set_text(this._store.readNote(this._selected.path));
// Erst nach set_text, das löst selbst text-changed aus. Sonst gilt jedes Öffnen als Änderung.
this._dirty = false;
this._viewScroll.hide();
this._editScroll.show();
this._editor.clutter_text.grab_key_focus();
this._editor.clutter_text.cursor_position = -1;
this._autosaveId = GLib.timeout_add_seconds(GLib.PRIORITY_DEFAULT, AUTOSAVE_SECONDS, () => {
this._flush();
return GLib.SOURCE_CONTINUE;
});
// Ein Klick außerhalb des Editors beendet das Bearbeiten
this._clickOutsideId = global.stage.connect('captured-event', (_stage, event) => {
if (event.type() === Clutter.EventType.BUTTON_PRESS && !this._editScroll.contains(global.stage.get_event_actor(event)))
this._stopEdit();
return Clutter.EVENT_PROPAGATE;
});
}
_flush() {
if (this._editing && this._dirty) {
this._store.writeNote(this._selected.path, this._editor.get_text());
this._dirty = false;
}
}
_stopAutosave() {
if (this._autosaveId) {
GLib.source_remove(this._autosaveId);
this._autosaveId = null;
}
if (this._clickOutsideId) {
global.stage.disconnect(this._clickOutsideId);
this._clickOutsideId = null;
}
}
_stopEdit() {
if (!this._editing)
return;
this._flush();
this._editing = false;
this._store.lockedNote = null;
this._stopAutosave();
// Nur die Anzeige wechseln. Ein Neuaufbau des Baums würde die gerade geklickte Zeile zerstören.
this._showSelected();
}
// ---- Anlegen, Umbenennen, Löschen ----
// Popover am Mauszeiger mit einem Namensfeld.
_namePopover(title, value, onSave) {
const popover = new Popover();
popover.addLabel(title, 'popover-title');
const entry = popover.addEntry(value, 'Name');
popover.setSave(() => onSave(entry.get_text().trim()));
popover.open(entry.clutter_text);
entry.clutter_text.set_selection(0, -1);
return popover;
}
_newNote(folder) {
let created = false;
const popover = this._namePopover('Neue Notiz', '', name => {
const path = this._store.createNote(folder, name);
this._expanded.add(folder);
this._select({type: 'note', path, name});
created = true;
});
// Erst nach dem Schließen, sonst holt sich das Popover den Tastaturfokus zurück.
popover.onClose = () => {
if (created)
this._startEdit();
};
}
_newFolder(parent) {
this._namePopover('Neuer Ordner', '', name => {
const path = this._store.createFolder(parent, name);
this._expanded.add(parent);
this._expanded.add(path);
this._select({type: 'folder', path, name});
});
}
_createItems(folder) {
return [
{label: 'Notiz erstellen', onSelect: () => this._newNote(folder)},
{label: 'Ordner erstellen', onSelect: () => this._newFolder(folder)},
];
}
_createMenu(folder) {
contextMenu(this._createItems(folder));
}
_rowMenu(type, node) {
const isFolder = type === 'folder';
const empty = isFolder ? this._store.folderEmpty(node.path) : true;
contextMenu([
...isFolder ? this._createItems(node.path) : [],
{label: 'Umbenennen', onSelect: () => this._rename(type, node)},
{label: empty ? 'Löschen' : 'Löschen (Ordner ist nicht leer)', sensitive: empty, onSelect: () => this._delete(type, node)},
]);
}
_rename(type, node) {
this._namePopover('Umbenennen', node.name, name => {
if (name === node.name)
return;
const path = type === 'note' ? this._store.renameNote(node.path, name) : this._store.renameFolder(node.path, name);
if (type === 'folder' && this._expanded.has(node.path)) {
this._expanded.delete(node.path);
this._expanded.add(path);
}
if (this._selected?.path === node.path)
this._select({type, path, name});
else
this._refresh();
});
}
_delete(type, node) {
if (this._selected?.path === node.path) {
this._editing = false;
this._store.lockedNote = null;
this._stopAutosave();
this._selected = null;
}
if (type === 'note')
this._store.deleteNote(node.path);
else
this._store.deleteFolder(node.path);
this._refresh();
}
});