310 lines
12 KiB
JavaScript
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();
|
|
}
|
|
});
|