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(); } });