HTML-Notizen mit WYSIWYG auf ProseMirror
Zweiter Notiz-Typ neben Markdown. Beide tragen dieselben Angaben, nur an ihrer jeweils üblichen Stelle: Markdown im Frontmatter, HTML in <title> und <meta name=…>. - domain/archive_html.rs: Gerüst, Metadaten, Titel setzen, Rumpf lesen und ersetzen, Text ohne Markup für den Suchindex. - Der Scan nimmt .html auf; Doc und WikiDocEntry führen `kind` (md|html). doc_path, set_title, write_body, ensure_ids und die Knotentext-Regel arbeiten typbewusst; ein Knotentext darf .md oder .html sein. - archive_create_html legt eine leere HTML-Notiz an; Anlegen schreibt nicht mehr in den Entwurfs-Puffer, sondern liefert die ID der neuen Notiz — die Ansicht wählt sie aus und öffnet den Editor. - Anzeige: HTML-Notizen erscheinen als Markup statt durch den Markdown-Renderer, mit klickbaren Wikilinks; eigenes Symbol im Baum. - Editor: html-editor.ts auf ProseMirror (MIT, ohne Anbieter-Stufe). DOMParser liest den Rumpf, DOMSerializer schreibt ihn zurück — gespeichert wird, was der Editor zeigt. Werkzeugleiste mit Überschriften, Auszeichnung, Listen, Ein-/Ausrücken, Tabellen und Links; Tastenkürzel für fett, kursiv, Rückgängig und Zellenwechsel. Markdown behält den Rohtext-Editor mit Live-Vorschau. - CSP: style-src erlaubt 'unsafe-inline', sonst blockiert die Richtlinie die Stil-Attribute in HTML-Notizen und alles erscheint unformatiert. script-src bleibt streng.
This commit is contained in:
Generated
+135
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "ai-control",
|
"name": "ai-control",
|
||||||
"version": "0.2.0",
|
"version": "0.3.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "ai-control",
|
"name": "ai-control",
|
||||||
"version": "0.2.0",
|
"version": "0.3.0",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fontsource/jetbrains-mono": "^5.2.8",
|
"@fontsource/jetbrains-mono": "^5.2.8",
|
||||||
@@ -20,6 +20,15 @@
|
|||||||
"@xterm/addon-webgl": "^0.19.0",
|
"@xterm/addon-webgl": "^0.19.0",
|
||||||
"@xterm/xterm": "^6.0.0",
|
"@xterm/xterm": "^6.0.0",
|
||||||
"marked": "^16.4.2",
|
"marked": "^16.4.2",
|
||||||
|
"prosemirror-commands": "^1.7.1",
|
||||||
|
"prosemirror-history": "^1.5.0",
|
||||||
|
"prosemirror-keymap": "^1.2.3",
|
||||||
|
"prosemirror-model": "^1.25.11",
|
||||||
|
"prosemirror-schema-basic": "^1.2.4",
|
||||||
|
"prosemirror-schema-list": "^1.5.1",
|
||||||
|
"prosemirror-state": "^1.4.4",
|
||||||
|
"prosemirror-tables": "^1.8.5",
|
||||||
|
"prosemirror-view": "^1.42.2",
|
||||||
"vue": "^3.5.39",
|
"vue": "^3.5.39",
|
||||||
"vue-i18n": "^11.4.6"
|
"vue-i18n": "^11.4.6"
|
||||||
},
|
},
|
||||||
@@ -1692,6 +1701,12 @@
|
|||||||
"ot": "bin/ot"
|
"ot": "bin/ot"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/orderedmap": {
|
||||||
|
"version": "2.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/orderedmap/-/orderedmap-2.1.1.tgz",
|
||||||
|
"integrity": "sha512-TvAWxi0nDe1j/rtMcWcIj94+Ffe6n7zhow33h40SKxmsmozs6dz/e+EajymfoFcHd7sxNn8yHM8839uixMOV6g==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/path-browserify": {
|
"node_modules/path-browserify": {
|
||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/path-browserify/-/path-browserify-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/path-browserify/-/path-browserify-1.0.1.tgz",
|
||||||
@@ -1762,6 +1777,112 @@
|
|||||||
"node": ">8.0.0"
|
"node": ">8.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/prosemirror-commands": {
|
||||||
|
"version": "1.7.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/prosemirror-commands/-/prosemirror-commands-1.7.1.tgz",
|
||||||
|
"integrity": "sha512-rT7qZnQtx5c0/y/KlYaGvtG411S97UaL6gdp6RIZ23DLHanMYLyfGBV5DtSnZdthQql7W+lEVbpSfwtO8T+L2w==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"prosemirror-model": "^1.0.0",
|
||||||
|
"prosemirror-state": "^1.0.0",
|
||||||
|
"prosemirror-transform": "^1.10.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/prosemirror-history": {
|
||||||
|
"version": "1.5.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/prosemirror-history/-/prosemirror-history-1.5.0.tgz",
|
||||||
|
"integrity": "sha512-zlzTiH01eKA55UAf1MEjtssJeHnGxO0j4K4Dpx+gnmX9n+SHNlDqI2oO1Kv1iPN5B1dm5fsljCfqKF9nFL6HRg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"prosemirror-state": "^1.2.2",
|
||||||
|
"prosemirror-transform": "^1.0.0",
|
||||||
|
"prosemirror-view": "^1.31.0",
|
||||||
|
"rope-sequence": "^1.3.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/prosemirror-keymap": {
|
||||||
|
"version": "1.2.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/prosemirror-keymap/-/prosemirror-keymap-1.2.3.tgz",
|
||||||
|
"integrity": "sha512-4HucRlpiLd1IPQQXNqeo81BGtkY8Ai5smHhKW9jjPKRc2wQIxksg7Hl1tTI2IfT2B/LgX6bfYvXxEpJl7aKYKw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"prosemirror-state": "^1.0.0",
|
||||||
|
"w3c-keyname": "^2.2.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/prosemirror-model": {
|
||||||
|
"version": "1.25.11",
|
||||||
|
"resolved": "https://registry.npmjs.org/prosemirror-model/-/prosemirror-model-1.25.11.tgz",
|
||||||
|
"integrity": "sha512-QWg9RhnpLlogAmp3p96uEFrE5txQpFynd4vhBAELkwgOCWQs/X0yCzB3/hrHqiPwf91RG5KyWq6553zs9JqIOQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"orderedmap": "^2.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/prosemirror-schema-basic": {
|
||||||
|
"version": "1.2.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/prosemirror-schema-basic/-/prosemirror-schema-basic-1.2.4.tgz",
|
||||||
|
"integrity": "sha512-ELxP4TlX3yr2v5rM7Sb70SqStq5NvI15c0j9j/gjsrO5vaw+fnnpovCLEGIcpeGfifkuqJwl4fon6b+KdrODYQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"prosemirror-model": "^1.25.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/prosemirror-schema-list": {
|
||||||
|
"version": "1.5.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/prosemirror-schema-list/-/prosemirror-schema-list-1.5.1.tgz",
|
||||||
|
"integrity": "sha512-927lFx/uwyQaGwJxLWCZRkjXG0p48KpMj6ueoYiu4JX05GGuGcgzAy62dfiV8eFZftgyBUvLx76RsMe20fJl+Q==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"prosemirror-model": "^1.0.0",
|
||||||
|
"prosemirror-state": "^1.0.0",
|
||||||
|
"prosemirror-transform": "^1.7.3"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/prosemirror-state": {
|
||||||
|
"version": "1.4.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/prosemirror-state/-/prosemirror-state-1.4.4.tgz",
|
||||||
|
"integrity": "sha512-6jiYHH2CIGbCfnxdHbXZ12gySFY/fz/ulZE333G6bPqIZ4F+TXo9ifiR86nAHpWnfoNjOb3o5ESi7J8Uz1jXHw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"prosemirror-model": "^1.0.0",
|
||||||
|
"prosemirror-transform": "^1.0.0",
|
||||||
|
"prosemirror-view": "^1.27.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/prosemirror-tables": {
|
||||||
|
"version": "1.8.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/prosemirror-tables/-/prosemirror-tables-1.8.5.tgz",
|
||||||
|
"integrity": "sha512-V/0cDCsHKHe/tfWkeCmthNUcEp1IVO3p6vwN8XtwE9PZQLAZJigbw3QoraAdfJPir4NKJtNvOB8oYGKRl+t0Dw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"prosemirror-keymap": "^1.2.3",
|
||||||
|
"prosemirror-model": "^1.25.4",
|
||||||
|
"prosemirror-state": "^1.4.4",
|
||||||
|
"prosemirror-transform": "^1.10.5",
|
||||||
|
"prosemirror-view": "^1.41.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/prosemirror-transform": {
|
||||||
|
"version": "1.12.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/prosemirror-transform/-/prosemirror-transform-1.12.0.tgz",
|
||||||
|
"integrity": "sha512-GxboyN4AMIsoHNtz5uf2r2Ru551i5hWeCMD6E2Ib4Eogqoub0NflniaBPVQ4MrGE5yZ8JV9tUHg9qcZTTrcN4w==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"prosemirror-model": "^1.21.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/prosemirror-view": {
|
||||||
|
"version": "1.42.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/prosemirror-view/-/prosemirror-view-1.42.2.tgz",
|
||||||
|
"integrity": "sha512-Pdg0l5kXm8aLDquFAnQFTCITg0q44sLqBlHlpsVLD9segdOao8TOfQdAhCrCXyVgPSRr6UDDROOIWA3bIrN9YQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"prosemirror-model": "^1.25.8",
|
||||||
|
"prosemirror-state": "^1.0.0",
|
||||||
|
"prosemirror-transform": "^1.1.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/rolldown": {
|
"node_modules/rolldown": {
|
||||||
"version": "1.1.4",
|
"version": "1.1.4",
|
||||||
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.1.4.tgz",
|
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.1.4.tgz",
|
||||||
@@ -1796,6 +1917,12 @@
|
|||||||
"@rolldown/binding-win32-x64-msvc": "1.1.4"
|
"@rolldown/binding-win32-x64-msvc": "1.1.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/rope-sequence": {
|
||||||
|
"version": "1.3.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/rope-sequence/-/rope-sequence-1.3.4.tgz",
|
||||||
|
"integrity": "sha512-UT5EDe2cu2E/6O4igUr5PSFs23nvvukicWHx6GnOPlHAiiYbzNuCRQCuiUdHJQcqKalLKlrYJnjY0ySGsXNQXQ==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/siginfo": {
|
"node_modules/siginfo": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz",
|
||||||
@@ -2139,6 +2266,12 @@
|
|||||||
"typescript": ">=5.0.0"
|
"typescript": ">=5.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/w3c-keyname": {
|
||||||
|
"version": "2.2.8",
|
||||||
|
"resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz",
|
||||||
|
"integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/whatwg-mimetype": {
|
"node_modules/whatwg-mimetype": {
|
||||||
"version": "3.0.0",
|
"version": "3.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-3.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-3.0.0.tgz",
|
||||||
|
|||||||
@@ -23,6 +23,15 @@
|
|||||||
"@xterm/addon-webgl": "^0.19.0",
|
"@xterm/addon-webgl": "^0.19.0",
|
||||||
"@xterm/xterm": "^6.0.0",
|
"@xterm/xterm": "^6.0.0",
|
||||||
"marked": "^16.4.2",
|
"marked": "^16.4.2",
|
||||||
|
"prosemirror-commands": "^1.7.1",
|
||||||
|
"prosemirror-history": "^1.5.0",
|
||||||
|
"prosemirror-keymap": "^1.2.3",
|
||||||
|
"prosemirror-model": "^1.25.11",
|
||||||
|
"prosemirror-schema-basic": "^1.2.4",
|
||||||
|
"prosemirror-schema-list": "^1.5.1",
|
||||||
|
"prosemirror-state": "^1.4.4",
|
||||||
|
"prosemirror-tables": "^1.8.5",
|
||||||
|
"prosemirror-view": "^1.42.2",
|
||||||
"vue": "^3.5.39",
|
"vue": "^3.5.39",
|
||||||
"vue-i18n": "^11.4.6"
|
"vue-i18n": "^11.4.6"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -74,6 +74,7 @@ fn main() {
|
|||||||
"archive_delete",
|
"archive_delete",
|
||||||
"archive_create_folder",
|
"archive_create_folder",
|
||||||
"archive_create_doc",
|
"archive_create_doc",
|
||||||
|
"archive_create_html",
|
||||||
"open_panel_window",
|
"open_panel_window",
|
||||||
])),
|
])),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -22,6 +22,7 @@
|
|||||||
"dialog:default",
|
"dialog:default",
|
||||||
"clipboard-manager:allow-write-text",
|
"clipboard-manager:allow-write-text",
|
||||||
"allow-archive-create-doc",
|
"allow-archive-create-doc",
|
||||||
|
"allow-archive-create-html",
|
||||||
"allow-archive-create-folder",
|
"allow-archive-create-folder",
|
||||||
"allow-archive-delete",
|
"allow-archive-delete",
|
||||||
"allow-archive-read",
|
"allow-archive-read",
|
||||||
|
|||||||
@@ -21,6 +21,7 @@
|
|||||||
"dialog:default",
|
"dialog:default",
|
||||||
"clipboard-manager:allow-write-text",
|
"clipboard-manager:allow-write-text",
|
||||||
"allow-archive-create-doc",
|
"allow-archive-create-doc",
|
||||||
|
"allow-archive-create-html",
|
||||||
"allow-archive-create-folder",
|
"allow-archive-create-folder",
|
||||||
"allow-archive-delete",
|
"allow-archive-delete",
|
||||||
"allow-archive-read",
|
"allow-archive-read",
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
# Automatically generated - DO NOT EDIT!
|
||||||
|
|
||||||
|
[[permission]]
|
||||||
|
identifier = "allow-archive-create-html"
|
||||||
|
description = "Enables the archive_create_html command without any pre-configured scope."
|
||||||
|
commands.allow = ["archive_create_html"]
|
||||||
|
|
||||||
|
[[permission]]
|
||||||
|
identifier = "deny-archive-create-html"
|
||||||
|
description = "Denies the archive_create_html command without any pre-configured scope."
|
||||||
|
commands.deny = ["archive_create_html"]
|
||||||
@@ -319,6 +319,7 @@ fn invoke_handlers() -> impl Fn(tauri::ipc::Invoke<tauri::Wry>) -> bool + Send +
|
|||||||
terminal::archive_delete,
|
terminal::archive_delete,
|
||||||
terminal::archive_create_folder,
|
terminal::archive_create_folder,
|
||||||
terminal::archive_create_doc,
|
terminal::archive_create_doc,
|
||||||
|
terminal::archive_create_html,
|
||||||
terminal::open_panel_window
|
terminal::open_panel_window
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,214 @@
|
|||||||
|
//! HTML-Notizen: Gegenstück zu den Frontmatter-Funktionen in archive.rs.
|
||||||
|
//! Dieselben Metadaten (id, title, project, created, description, tags)
|
||||||
|
//! stehen hier an den Stellen, die HTML dafür vorsieht — `<title>` und
|
||||||
|
//! `<meta name=…>`. Geparst wird mit denselben Mitteln wie der
|
||||||
|
//! Frontmatter-Block: Zeichenkettensuche, kein HTML-Parser; die Dateien
|
||||||
|
//! schreibt dieselbe Anwendung.
|
||||||
|
|
||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
|
/// Gerüst einer neuen HTML-Notiz.
|
||||||
|
pub(crate) fn skeleton(title: &str, project: &str, iso: &str) -> String {
|
||||||
|
format!(
|
||||||
|
"<!doctype html>\n<html>\n<head>\n<meta charset=\"utf-8\">\n\
|
||||||
|
<meta name=\"id\" content=\"{}\">\n\
|
||||||
|
<meta name=\"project\" content=\"{}\">\n\
|
||||||
|
<meta name=\"created\" content=\"{iso}\">\n\
|
||||||
|
<meta name=\"source\" content=\"ai-control\">\n\
|
||||||
|
<title>{}</title>\n</head>\n<body>\n</body>\n</html>\n",
|
||||||
|
uuid::Uuid::new_v4(),
|
||||||
|
escape(project),
|
||||||
|
escape(title),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Metadaten einer HTML-Notiz: `<title>` plus alle `<meta name=… content=…>`.
|
||||||
|
/// Schlagwörter stehen in `keywords`, kommagetrennt.
|
||||||
|
pub(crate) fn parse_meta(text: &str) -> HashMap<String, String> {
|
||||||
|
let mut map = HashMap::new();
|
||||||
|
if let Some(title) = between(text, "<title>", "</title>") {
|
||||||
|
map.insert("title".to_string(), unescape(title.trim()));
|
||||||
|
}
|
||||||
|
let mut rest = text;
|
||||||
|
while let Some(start) = rest.find("<meta ") {
|
||||||
|
rest = &rest[start + 6..];
|
||||||
|
let Some(end) = rest.find('>') else {
|
||||||
|
break;
|
||||||
|
};
|
||||||
|
let tag = &rest[..end];
|
||||||
|
rest = &rest[end + 1..];
|
||||||
|
if let (Some(name), Some(content)) = (attr(tag, "name"), attr(tag, "content")) {
|
||||||
|
map.insert(name, unescape(&content));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
map
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Setzt den Anzeige-Titel: den Inhalt von `<title>`.
|
||||||
|
pub(crate) fn set_title(text: &str, title: &str) -> Result<String, String> {
|
||||||
|
let start = text.find("<title>").ok_or("kein <title> in der HTML-Notiz")?;
|
||||||
|
let end = text.find("</title>").ok_or("kein <title> in der HTML-Notiz")?;
|
||||||
|
Ok(format!("{}{}{}", &text[..start + 7], escape(title), &text[end..]))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Setzt ein `<meta name=… content=…>`; fehlt es, kommt es vor den `<title>`.
|
||||||
|
pub(crate) fn set_meta(text: &str, name: &str, value: &str) -> Result<String, String> {
|
||||||
|
let line = format!("<meta name=\"{name}\" content=\"{}\">", escape(value));
|
||||||
|
let mut rest = text;
|
||||||
|
let mut offset = 0;
|
||||||
|
while let Some(start) = rest.find("<meta ") {
|
||||||
|
let abs = offset + start;
|
||||||
|
rest = &rest[start + 6..];
|
||||||
|
offset = abs + 6;
|
||||||
|
let Some(end) = rest.find('>') else {
|
||||||
|
break;
|
||||||
|
};
|
||||||
|
if attr(&rest[..end], "name").as_deref() == Some(name) {
|
||||||
|
return Ok(format!("{}{line}{}", &text[..abs], &text[offset + end + 1..]));
|
||||||
|
}
|
||||||
|
rest = &rest[end + 1..];
|
||||||
|
offset += end + 1;
|
||||||
|
}
|
||||||
|
let at = text.find("<title>").ok_or("kein <head> in der HTML-Notiz")?;
|
||||||
|
Ok(format!("{}{line}\n{}", &text[..at], &text[at..]))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Rumpf der Notiz: Inhalt zwischen `<body>` und `</body>`; ohne die Marken
|
||||||
|
/// gilt der ganze Text als Rumpf.
|
||||||
|
pub(crate) fn body(text: &str) -> &str {
|
||||||
|
match (text.find("<body>"), text.rfind("</body>")) {
|
||||||
|
(Some(a), Some(b)) if b > a + 6 => text[a + 6..b].trim_matches('\n'),
|
||||||
|
_ => text,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ersetzt den Rumpf; Kopf und Marken bleiben.
|
||||||
|
pub(crate) fn replace_body(text: &str, html: &str) -> Result<String, String> {
|
||||||
|
let a = text.find("<body>").ok_or("kein <body> in der HTML-Notiz")?;
|
||||||
|
let b = text.rfind("</body>").ok_or("kein <body> in der HTML-Notiz")?;
|
||||||
|
Ok(format!("{}\n{}\n{}", &text[..a + 6], html.trim(), &text[b..]))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reiner Text einer HTML-Notiz — Grundlage für den Suchindex.
|
||||||
|
pub(crate) fn strip_tags(text: &str) -> String {
|
||||||
|
let mut out = String::new();
|
||||||
|
let mut rest = body(text);
|
||||||
|
// Skript- und Stilblöcke tragen keinen Lesetext.
|
||||||
|
for marker in ["script", "style"] {
|
||||||
|
while let Some(a) = rest.find(&format!("<{marker}")) {
|
||||||
|
let Some(b) = rest[a..].find(&format!("</{marker}>")) else {
|
||||||
|
break;
|
||||||
|
};
|
||||||
|
let cut = format!("{}{}", &rest[..a], &rest[a + b + marker.len() + 3..]);
|
||||||
|
out.clear();
|
||||||
|
return strip_tags_inner(&cut);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
strip_tags_inner(rest)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn strip_tags_inner(text: &str) -> String {
|
||||||
|
let mut out = String::new();
|
||||||
|
let mut in_tag = false;
|
||||||
|
for c in text.chars() {
|
||||||
|
match c {
|
||||||
|
'<' => in_tag = true,
|
||||||
|
'>' => {
|
||||||
|
in_tag = false;
|
||||||
|
out.push(' ');
|
||||||
|
}
|
||||||
|
_ if !in_tag => out.push(c),
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
unescape(out.split_whitespace().collect::<Vec<_>>().join(" ").as_str())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Wert eines Attributs im Tag-Inneren (`name="wert"`).
|
||||||
|
fn attr(tag: &str, name: &str) -> Option<String> {
|
||||||
|
let key = format!("{name}=\"");
|
||||||
|
let start = tag.find(&key)? + key.len();
|
||||||
|
let end = tag[start..].find('"')? + start;
|
||||||
|
Some(tag[start..end].to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn between<'a>(text: &'a str, open: &str, close: &str) -> Option<&'a str> {
|
||||||
|
let start = text.find(open)? + open.len();
|
||||||
|
let end = text[start..].find(close)? + start;
|
||||||
|
Some(&text[start..end])
|
||||||
|
}
|
||||||
|
|
||||||
|
fn escape(s: &str) -> String {
|
||||||
|
s.replace('&', "&").replace('<', "<").replace('>', ">").replace('"', """)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn unescape(s: &str) -> String {
|
||||||
|
s.replace(""", "\"")
|
||||||
|
.replace("<", "<")
|
||||||
|
.replace(">", ">")
|
||||||
|
.replace("&", "&")
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn doc() -> String {
|
||||||
|
skeleton("Titel", "proj", "2026-07-26T10:00:00Z")
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn geruest_traegt_id_und_titel() {
|
||||||
|
let html = doc();
|
||||||
|
let meta = parse_meta(&html);
|
||||||
|
assert_eq!(meta.get("title").map(String::as_str), Some("Titel"));
|
||||||
|
assert_eq!(meta.get("id").map(String::len), Some(36));
|
||||||
|
assert_eq!(meta.get("project").map(String::as_str), Some("proj"));
|
||||||
|
assert_eq!(meta.get("created").map(String::as_str), Some("2026-07-26T10:00:00Z"));
|
||||||
|
assert_eq!(body(&html), "");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn titel_und_meta_setzen() {
|
||||||
|
let html = set_title(&doc(), "Neuer <Titel>").unwrap();
|
||||||
|
assert_eq!(parse_meta(&html).get("title").map(String::as_str), Some("Neuer <Titel>"));
|
||||||
|
|
||||||
|
// Vorhandenes meta wird ersetzt, fehlendes ergänzt.
|
||||||
|
let html = set_meta(&html, "project", "andere").unwrap();
|
||||||
|
let html = set_meta(&html, "description", "Kurz \"gefasst\"").unwrap();
|
||||||
|
let meta = parse_meta(&html);
|
||||||
|
assert_eq!(meta.get("project").map(String::as_str), Some("andere"));
|
||||||
|
assert_eq!(meta.get("description").map(String::as_str), Some("Kurz \"gefasst\""));
|
||||||
|
assert_eq!(meta.get("title").map(String::as_str), Some("Neuer <Titel>"));
|
||||||
|
// Kein Duplikat.
|
||||||
|
assert_eq!(html.matches("name=\"project\"").count(), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rumpf_lesen_und_ersetzen() {
|
||||||
|
let html = replace_body(&doc(), "<p>Erster Absatz</p>").unwrap();
|
||||||
|
assert_eq!(body(&html), "<p>Erster Absatz</p>");
|
||||||
|
let html = replace_body(&html, "<p>Zweiter</p>").unwrap();
|
||||||
|
assert_eq!(body(&html), "<p>Zweiter</p>");
|
||||||
|
// Der Kopf bleibt unangetastet.
|
||||||
|
assert_eq!(parse_meta(&html).get("title").map(String::as_str), Some("Titel"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn suchtext_ohne_markup() {
|
||||||
|
let html = replace_body(
|
||||||
|
&doc(),
|
||||||
|
"<h1>Über&#nbsp;schrift</h1>\n<p>Text mit <b>Fett</b> und & Zeichen.</p>",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let text = strip_tags(&html);
|
||||||
|
assert!(text.contains("Text mit Fett und & Zeichen."));
|
||||||
|
assert!(!text.contains('<'));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn fehlende_marken_brechen_laut_ab() {
|
||||||
|
assert!(set_title("<html></html>", "T").is_err());
|
||||||
|
assert!(replace_body("<html></html>", "x").is_err());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -17,6 +17,8 @@ pub(crate) struct Doc {
|
|||||||
pub(crate) id: String,
|
pub(crate) id: String,
|
||||||
/// Pfad relativ zum Archiv-Home (Eigenschaft, keine Identität).
|
/// Pfad relativ zum Archiv-Home (Eigenschaft, keine Identität).
|
||||||
pub(crate) relpath: String,
|
pub(crate) relpath: String,
|
||||||
|
/// Notiz-Typ: `md` oder `html` — bestimmt Anzeige und Editor.
|
||||||
|
pub(crate) kind: &'static str,
|
||||||
/// Wikilink-Name: Datei-Stem ohne führenden Zeitstempel.
|
/// Wikilink-Name: Datei-Stem ohne führenden Zeitstempel.
|
||||||
pub(crate) name: String,
|
pub(crate) name: String,
|
||||||
/// Frontmatter-Titel, sonst der Name.
|
/// Frontmatter-Titel, sonst der Name.
|
||||||
@@ -91,7 +93,7 @@ fn walk(home: &Path, dir: &Path, docs: &mut Vec<(Doc, String)>) -> Result<(), St
|
|||||||
}
|
}
|
||||||
if path.is_dir() {
|
if path.is_dir() {
|
||||||
walk(home, &path, docs)?;
|
walk(home, &path, docs)?;
|
||||||
} else if fname.ends_with(".md") {
|
} else if fname.ends_with(".md") || fname.ends_with(".html") {
|
||||||
docs.push(read_doc(home, &path)?);
|
docs.push(read_doc(home, &path)?);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -109,7 +111,15 @@ fn read_doc(home: &Path, path: &Path) -> Result<(Doc, String), String> {
|
|||||||
let modified = crate::domain::archive::utc_stamp(mtime).1[..10].to_string();
|
let modified = crate::domain::archive::utc_stamp(mtime).1[..10].to_string();
|
||||||
let stem = path.file_stem().unwrap_or_default().to_string_lossy().to_string();
|
let stem = path.file_stem().unwrap_or_default().to_string_lossy().to_string();
|
||||||
let name = strip_stamp(&stem).to_string();
|
let name = strip_stamp(&stem).to_string();
|
||||||
let fm = parse_frontmatter(&text);
|
let html = path.extension().is_some_and(|e| e == "html");
|
||||||
|
let kind = if html { "html" } else { "md" };
|
||||||
|
// Beide Typen tragen dieselben Angaben, nur an ihrer jeweils üblichen
|
||||||
|
// Stelle: Markdown im Frontmatter, HTML in <title>/<meta>.
|
||||||
|
let fm = if html {
|
||||||
|
crate::domain::archive_html::parse_meta(&text)
|
||||||
|
} else {
|
||||||
|
parse_frontmatter(&text)
|
||||||
|
};
|
||||||
let relpath = path
|
let relpath = path
|
||||||
.strip_prefix(home)
|
.strip_prefix(home)
|
||||||
.map_err(|e| format!("{}: {e}", path.display()))?
|
.map_err(|e| format!("{}: {e}", path.display()))?
|
||||||
@@ -120,14 +130,23 @@ fn read_doc(home: &Path, path: &Path) -> Result<(Doc, String), String> {
|
|||||||
relpath,
|
relpath,
|
||||||
title: fm.get("title").unwrap_or(&name).clone(),
|
title: fm.get("title").unwrap_or(&name).clone(),
|
||||||
description: fm.get("description").cloned(),
|
description: fm.get("description").cloned(),
|
||||||
tags: fm.get("tags").map(|t| parse_tag_list(t)).unwrap_or_default(),
|
tags: fm
|
||||||
|
.get(if html { "keywords" } else { "tags" })
|
||||||
|
.map(|t| parse_tag_list(t))
|
||||||
|
.unwrap_or_default(),
|
||||||
created: fm.get("created").cloned(),
|
created: fm.get("created").cloned(),
|
||||||
links: wikilinks(&text),
|
links: wikilinks(&text),
|
||||||
backlinks: Vec::new(),
|
backlinks: Vec::new(),
|
||||||
modified,
|
modified,
|
||||||
|
kind,
|
||||||
name,
|
name,
|
||||||
};
|
};
|
||||||
Ok((doc, text))
|
let indexed = if html {
|
||||||
|
crate::domain::archive_html::strip_tags(&text)
|
||||||
|
} else {
|
||||||
|
text
|
||||||
|
};
|
||||||
|
Ok((doc, indexed))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Slug-Vergleich eines Wikilink-Ziels gegen Name, Titel und Datei-Stem.
|
/// Slug-Vergleich eines Wikilink-Ziels gegen Name, Titel und Datei-Stem.
|
||||||
@@ -170,6 +189,8 @@ pub(crate) struct WikiFolder {
|
|||||||
pub(crate) struct WikiDocEntry {
|
pub(crate) struct WikiDocEntry {
|
||||||
/// Technische ID — Adressat aller Aktionen.
|
/// Technische ID — Adressat aller Aktionen.
|
||||||
pub(crate) id: String,
|
pub(crate) id: String,
|
||||||
|
/// Notiz-Typ (`md`/`html`) — Symbol im Baum, Anzeige und Editor.
|
||||||
|
pub(crate) kind: &'static str,
|
||||||
/// Pfad relativ zum Archiv-Home — Sprung ins Dokument und Adressat der
|
/// Pfad relativ zum Archiv-Home — Sprung ins Dokument und Adressat der
|
||||||
/// Zeilen-Aktionen (umbenennen, löschen).
|
/// Zeilen-Aktionen (umbenennen, löschen).
|
||||||
pub(crate) relpath: String,
|
pub(crate) relpath: String,
|
||||||
@@ -265,8 +286,14 @@ pub(crate) fn folder_nodes(home: &Path) -> Result<Vec<FolderNode>, String> {
|
|||||||
.iter()
|
.iter()
|
||||||
.map(|ext| home.join(&path).with_extension(ext))
|
.map(|ext| home.join(&path).with_extension(ext))
|
||||||
.find(|p| p.is_file())
|
.find(|p| p.is_file())
|
||||||
.and_then(|p| fs::read_to_string(p).ok())
|
.and_then(|p| Some((p.extension()?.to_string_lossy().to_string(), fs::read_to_string(p).ok()?)))
|
||||||
.map(|text| parse_frontmatter(&text))
|
.map(|(ext, text)| {
|
||||||
|
if ext == "html" {
|
||||||
|
crate::domain::archive_html::parse_meta(&text)
|
||||||
|
} else {
|
||||||
|
parse_frontmatter(&text)
|
||||||
|
}
|
||||||
|
})
|
||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
let title = fm.get("title").cloned().unwrap_or_else(|| name.clone());
|
let title = fm.get("title").cloned().unwrap_or_else(|| name.clone());
|
||||||
let id = fm.get("id").cloned().unwrap_or_default();
|
let id = fm.get("id").cloned().unwrap_or_default();
|
||||||
@@ -310,6 +337,7 @@ fn doc_entry(doc: &Doc) -> WikiDocEntry {
|
|||||||
});
|
});
|
||||||
WikiDocEntry {
|
WikiDocEntry {
|
||||||
id: doc.id.clone(),
|
id: doc.id.clone(),
|
||||||
|
kind: doc.kind,
|
||||||
relpath: doc.relpath.clone(),
|
relpath: doc.relpath.clone(),
|
||||||
name: doc.name.clone(),
|
name: doc.name.clone(),
|
||||||
title: doc.title.clone(),
|
title: doc.title.clone(),
|
||||||
|
|||||||
@@ -27,8 +27,8 @@ fn checked_rel(rel: &str) -> Result<(), String> {
|
|||||||
/// Vorhandenes Archiv-Dokument zum relpath.
|
/// Vorhandenes Archiv-Dokument zum relpath.
|
||||||
pub(crate) fn doc_path(home: &Path, relpath: &str) -> Result<PathBuf, String> {
|
pub(crate) fn doc_path(home: &Path, relpath: &str) -> Result<PathBuf, String> {
|
||||||
checked_rel(relpath)?;
|
checked_rel(relpath)?;
|
||||||
if !relpath.ends_with(".md") {
|
if !relpath.ends_with(".md") && !relpath.ends_with(".html") {
|
||||||
return Err(format!("kein Markdown-Dokument: {relpath}"));
|
return Err(format!("keine Notiz-Datei: {relpath}"));
|
||||||
}
|
}
|
||||||
let path = home.join(relpath);
|
let path = home.join(relpath);
|
||||||
if !path.is_file() {
|
if !path.is_file() {
|
||||||
@@ -37,6 +37,11 @@ pub(crate) fn doc_path(home: &Path, relpath: &str) -> Result<PathBuf, String> {
|
|||||||
Ok(path)
|
Ok(path)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Ist die Datei eine HTML-Notiz?
|
||||||
|
fn is_html(path: &Path) -> bool {
|
||||||
|
path.extension().is_some_and(|e| e == "html")
|
||||||
|
}
|
||||||
|
|
||||||
/// Ziel für rename: darf noch nicht existieren — stilles Überschreiben wäre
|
/// Ziel für rename: darf noch nicht existieren — stilles Überschreiben wäre
|
||||||
/// Datenverlust.
|
/// Datenverlust.
|
||||||
fn fresh_target(target: &Path, home: &Path) -> Result<(), String> {
|
fn fresh_target(target: &Path, home: &Path) -> Result<(), String> {
|
||||||
@@ -112,6 +117,40 @@ pub(crate) fn create_doc(
|
|||||||
Ok(target.strip_prefix(home).unwrap().display().to_string())
|
Ok(target.strip_prefix(home).unwrap().display().to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Legt eine leere HTML-Notiz an: `<slug>.html` im Ordner (leer = Wurzel).
|
||||||
|
/// Liefert den relpath.
|
||||||
|
pub(crate) fn create_html(
|
||||||
|
home: &Path,
|
||||||
|
folder: &str,
|
||||||
|
name: &str,
|
||||||
|
project_name: &str,
|
||||||
|
) -> Result<String, String> {
|
||||||
|
let name = name.trim();
|
||||||
|
if name.is_empty() {
|
||||||
|
return Err("Dokumentname fehlt".into());
|
||||||
|
}
|
||||||
|
if !folder.is_empty() {
|
||||||
|
checked_rel(folder)?;
|
||||||
|
}
|
||||||
|
let dir = home.join(folder);
|
||||||
|
fs::create_dir_all(&dir).map_err(|e| format!("{}: {e}", dir.display()))?;
|
||||||
|
let target = dir.join(format!("{}.html", slugify(name)));
|
||||||
|
let doc = crate::domain::archive_html::skeleton(name, project_name, &utc_now_iso()?);
|
||||||
|
let mut file = match fs::OpenOptions::new().write(true).create_new(true).open(&target) {
|
||||||
|
Ok(f) => f,
|
||||||
|
Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {
|
||||||
|
return Err(format!(
|
||||||
|
"Ziel existiert bereits: {}",
|
||||||
|
target.strip_prefix(home).unwrap_or(&target).display()
|
||||||
|
))
|
||||||
|
}
|
||||||
|
Err(e) => return Err(format!("{}: {e}", target.display())),
|
||||||
|
};
|
||||||
|
std::io::Write::write_all(&mut file, doc.as_bytes())
|
||||||
|
.map_err(|e| format!("{}: {e}", target.display()))?;
|
||||||
|
Ok(target.strip_prefix(home).unwrap().display().to_string())
|
||||||
|
}
|
||||||
|
|
||||||
/// Setzt den Anzeige-Titel einer Notiz: `title:` im Frontmatter der Datei.
|
/// Setzt den Anzeige-Titel einer Notiz: `title:` im Frontmatter der Datei.
|
||||||
/// Der Dateiname bleibt — er ist der technische Name (Modellvorgabe: der
|
/// Der Dateiname bleibt — er ist der technische Name (Modellvorgabe: der
|
||||||
/// angezeigte Titel steht im Text). Ohne Frontmatter-Block bekommt die Datei
|
/// angezeigte Titel steht im Text). Ohne Frontmatter-Block bekommt die Datei
|
||||||
@@ -122,6 +161,10 @@ pub(crate) fn set_title(path: &Path, title: &str) -> Result<(), String> {
|
|||||||
return Err("Titel fehlt".into());
|
return Err("Titel fehlt".into());
|
||||||
}
|
}
|
||||||
let doc = fs::read_to_string(path).map_err(|e| format!("{}: {e}", path.display()))?;
|
let doc = fs::read_to_string(path).map_err(|e| format!("{}: {e}", path.display()))?;
|
||||||
|
if is_html(path) {
|
||||||
|
let out = crate::domain::archive_html::set_title(&doc, title)?;
|
||||||
|
return fs::write(path, out).map_err(|e| format!("{}: {e}", path.display()));
|
||||||
|
}
|
||||||
let rest = doc
|
let rest = doc
|
||||||
.strip_prefix("---\n")
|
.strip_prefix("---\n")
|
||||||
.ok_or_else(|| format!("kein Frontmatter-Block: {}", path.display()))?;
|
.ok_or_else(|| format!("kein Frontmatter-Block: {}", path.display()))?;
|
||||||
@@ -143,6 +186,10 @@ pub(crate) fn set_title(path: &Path, title: &str) -> Result<(), String> {
|
|||||||
/// ersetzt.
|
/// ersetzt.
|
||||||
pub(crate) fn write_body(path: &Path, text: &str) -> Result<(), String> {
|
pub(crate) fn write_body(path: &Path, text: &str) -> Result<(), String> {
|
||||||
let doc = fs::read_to_string(path).map_err(|e| format!("{}: {e}", path.display()))?;
|
let doc = fs::read_to_string(path).map_err(|e| format!("{}: {e}", path.display()))?;
|
||||||
|
if is_html(path) {
|
||||||
|
let out = crate::domain::archive_html::replace_body(&doc, text)?;
|
||||||
|
return fs::write(path, out).map_err(|e| format!("{}: {e}", path.display()));
|
||||||
|
}
|
||||||
let head = doc.len() - strip_frontmatter(&doc).len();
|
let head = doc.len() - strip_frontmatter(&doc).len();
|
||||||
let out = format!("{}{}\n", &doc[..head], text.trim_end());
|
let out = format!("{}{}\n", &doc[..head], text.trim_end());
|
||||||
fs::write(path, out).map_err(|e| format!("{}: {e}", path.display()))
|
fs::write(path, out).map_err(|e| format!("{}: {e}", path.display()))
|
||||||
@@ -182,8 +229,17 @@ pub(crate) fn move_folder(home: &Path, folder: &str, to: &str) -> Result<(), Str
|
|||||||
/// über die ID. Dateien ohne ID (von Hand angelegt, Altbestand) bekommen hier
|
/// über die ID. Dateien ohne ID (von Hand angelegt, Altbestand) bekommen hier
|
||||||
/// eine.
|
/// eine.
|
||||||
pub(crate) fn ensure_ids(home: &Path) -> Result<(), String> {
|
pub(crate) fn ensure_ids(home: &Path) -> Result<(), String> {
|
||||||
for path in md_files(home)? {
|
for path in note_files(home)? {
|
||||||
let text = fs::read_to_string(&path).map_err(|e| format!("{}: {e}", path.display()))?;
|
let text = fs::read_to_string(&path).map_err(|e| format!("{}: {e}", path.display()))?;
|
||||||
|
if is_html(&path) {
|
||||||
|
if crate::domain::archive_html::parse_meta(&text).contains_key("id") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let id = uuid::Uuid::new_v4().to_string();
|
||||||
|
let out = crate::domain::archive_html::set_meta(&text, "id", &id)?;
|
||||||
|
fs::write(&path, out).map_err(|e| format!("{}: {e}", path.display()))?;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
if crate::domain::archive::parse_frontmatter(&text).contains_key("id") {
|
if crate::domain::archive::parse_frontmatter(&text).contains_key("id") {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -199,9 +255,9 @@ pub(crate) fn ensure_ids(home: &Path) -> Result<(), String> {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Alle Markdown-Dateien unterhalb von `dir`, rekursiv; versteckte Einträge
|
/// Alle Notiz-Dateien (`.md`, `.html`) unterhalb von `dir`, rekursiv;
|
||||||
/// bleiben außen vor.
|
/// versteckte Einträge bleiben außen vor.
|
||||||
fn md_files(dir: &Path) -> Result<Vec<PathBuf>, String> {
|
fn note_files(dir: &Path) -> Result<Vec<PathBuf>, String> {
|
||||||
let mut out = Vec::new();
|
let mut out = Vec::new();
|
||||||
for entry in fs::read_dir(dir).map_err(|e| format!("{}: {e}", dir.display()))? {
|
for entry in fs::read_dir(dir).map_err(|e| format!("{}: {e}", dir.display()))? {
|
||||||
let entry = entry.map_err(|e| format!("{}: {e}", dir.display()))?;
|
let entry = entry.map_err(|e| format!("{}: {e}", dir.display()))?;
|
||||||
@@ -211,8 +267,8 @@ fn md_files(dir: &Path) -> Result<Vec<PathBuf>, String> {
|
|||||||
}
|
}
|
||||||
let path = entry.path();
|
let path = entry.path();
|
||||||
if path.is_dir() {
|
if path.is_dir() {
|
||||||
out.extend(md_files(&path)?);
|
out.extend(note_files(&path)?);
|
||||||
} else if name.ends_with(".md") {
|
} else if name.ends_with(".md") || name.ends_with(".html") {
|
||||||
out.push(path);
|
out.push(path);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -254,14 +310,16 @@ fn ensure_dir_texts(dir: &Path, project_name: &str) -> Result<(), String> {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Namen (Stem ohne Zeitstempel) aller Markdown-Dateien direkt im Ordner.
|
/// Namen (Stem ohne Zeitstempel) aller Notiz-Dateien direkt im Ordner.
|
||||||
fn md_stems(dir: &Path) -> Result<std::collections::HashSet<String>, String> {
|
fn md_stems(dir: &Path) -> Result<std::collections::HashSet<String>, String> {
|
||||||
let mut out = std::collections::HashSet::new();
|
let mut out = std::collections::HashSet::new();
|
||||||
for entry in fs::read_dir(dir).map_err(|e| format!("{}: {e}", dir.display()))? {
|
for entry in fs::read_dir(dir).map_err(|e| format!("{}: {e}", dir.display()))? {
|
||||||
let entry = entry.map_err(|e| format!("{}: {e}", dir.display()))?;
|
let entry = entry.map_err(|e| format!("{}: {e}", dir.display()))?;
|
||||||
let name = entry.file_name().to_string_lossy().to_string();
|
let name = entry.file_name().to_string_lossy().to_string();
|
||||||
if let Some(stem) = name.strip_suffix(".md") {
|
for ext in [".md", ".html"] {
|
||||||
out.insert(strip_stamp(stem).to_string());
|
if let Some(stem) = name.strip_suffix(ext) {
|
||||||
|
out.insert(strip_stamp(stem).to_string());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Ok(out)
|
Ok(out)
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
|
|
||||||
pub(crate) mod archive;
|
pub(crate) mod archive;
|
||||||
pub(crate) mod archive_index;
|
pub(crate) mod archive_index;
|
||||||
|
pub(crate) mod archive_html;
|
||||||
pub(crate) mod archive_ops;
|
pub(crate) mod archive_ops;
|
||||||
pub(crate) mod archive_search;
|
pub(crate) mod archive_search;
|
||||||
pub(crate) mod credentials;
|
pub(crate) mod credentials;
|
||||||
|
|||||||
@@ -690,15 +690,49 @@ fn join_under(
|
|||||||
/// frisch (zwei konkurrierende Puffer-Events würden sonst um den aktiven Tab
|
/// frisch (zwei konkurrierende Puffer-Events würden sonst um den aktiven Tab
|
||||||
/// rennen).
|
/// rennen).
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub fn archive_create_doc(project: String, parent: String, name: String) -> Result<(), String> {
|
pub fn archive_create_doc(
|
||||||
|
project: String,
|
||||||
|
parent: String,
|
||||||
|
name: String,
|
||||||
|
) -> Result<String, String> {
|
||||||
let home = crate::domain::archive::require_archive_home(&project)?;
|
let home = crate::domain::archive::require_archive_home(&project)?;
|
||||||
let display = crate::domain::project::display_name_in(&Paths::real(), &project)?;
|
let display = crate::domain::project::display_name_in(&Paths::real(), &project)?;
|
||||||
let rel_new = join_under(&home, &parent, &name)?;
|
let rel_new = join_under(&home, &parent, &name)?;
|
||||||
let folder = rel_new.rsplit_once('/').map(|(h, _)| h.to_string()).unwrap_or_default();
|
let folder = rel_new.rsplit_once('/').map(|(h, _)| h.to_string()).unwrap_or_default();
|
||||||
let rel = crate::domain::archive_ops::create_doc(&home, &folder, &name, &display)?;
|
let rel = crate::domain::archive_ops::create_doc(&home, &folder, &name, &display)?;
|
||||||
let path = home.join(rel);
|
// Bearbeitet wird im Archiv — der Entwurfs-Puffer bleibt unberührt; die
|
||||||
std::fs::write(panel_file(&project), "").map_err(|e| e.to_string())?;
|
// frische Übersicht zeigt die neue Notiz, ihre ID öffnet dort den Editor.
|
||||||
link_panel_source(&project, &path)
|
crate::domain::archive_ops::ensure_ids(&home)?;
|
||||||
|
wiki_refresh_page(&project, &home)?;
|
||||||
|
note_id(&home.join(rel))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Technische ID einer eben angelegten Notiz.
|
||||||
|
fn note_id(path: &std::path::Path) -> Result<String, String> {
|
||||||
|
let text = std::fs::read_to_string(path).map_err(|e| format!("{}: {e}", path.display()))?;
|
||||||
|
let meta = if path.extension().is_some_and(|e| e == "html") {
|
||||||
|
crate::domain::archive_html::parse_meta(&text)
|
||||||
|
} else {
|
||||||
|
crate::domain::archive::parse_frontmatter(&text)
|
||||||
|
};
|
||||||
|
meta.get("id").cloned().ok_or_else(|| format!("keine ID in {}", path.display()))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Legt eine leere HTML-Notiz unter dem Knoten an (Kontextmenü im Baum).
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn archive_create_html(
|
||||||
|
project: String,
|
||||||
|
parent: String,
|
||||||
|
name: String,
|
||||||
|
) -> Result<String, String> {
|
||||||
|
let home = crate::domain::archive::require_archive_home(&project)?;
|
||||||
|
let display = crate::domain::project::display_name_in(&Paths::real(), &project)?;
|
||||||
|
let rel_new = join_under(&home, &parent, &name)?;
|
||||||
|
let folder = rel_new.rsplit_once('/').map(|(h, _)| h.to_string()).unwrap_or_default();
|
||||||
|
let rel = crate::domain::archive_ops::create_html(&home, &folder, &name, &display)?;
|
||||||
|
crate::domain::archive_ops::ensure_ids(&home)?;
|
||||||
|
wiki_refresh_page(&project, &home)?;
|
||||||
|
note_id(&home.join(rel))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Löst das Panel in ein eigenes Fenster ab. Existiert es schon, kommt es nach
|
/// Löst das Panel in ein eigenes Fenster ab. Existiert es schon, kommt es nach
|
||||||
|
|||||||
@@ -13,7 +13,7 @@
|
|||||||
"macOSPrivateApi": true,
|
"macOSPrivateApi": true,
|
||||||
"windows": [],
|
"windows": [],
|
||||||
"security": {
|
"security": {
|
||||||
"csp": "default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data:; connect-src ipc: http://ipc.localhost",
|
"csp": "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src ipc: http://ipc.localhost",
|
||||||
"devCsp": "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src ipc: http://ipc.localhost ws://localhost:1420 http://localhost:1420"
|
"devCsp": "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src ipc: http://ipc.localhost ws://localhost:1420 http://localhost:1420"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -0,0 +1,173 @@
|
|||||||
|
/// WYSIWYG-Editor für HTML-Notizen, direkt auf ProseMirror (MIT, ohne
|
||||||
|
/// Anbieter-Stufe). HTML ist hier nicht Umweg, sondern Format: `DOMParser`
|
||||||
|
/// liest den Rumpf der Notiz ins Dokumentmodell, `DOMSerializer` schreibt ihn
|
||||||
|
/// zurück — was gespeichert wird, ist genau das, was der Editor zeigt.
|
||||||
|
/// Markdown-Notizen behalten den Rohtext-Editor mit Vorschau.
|
||||||
|
|
||||||
|
import { baseKeymap, chainCommands, setBlockType, toggleMark } from "prosemirror-commands";
|
||||||
|
import { history, redo, undo } from "prosemirror-history";
|
||||||
|
import { keymap } from "prosemirror-keymap";
|
||||||
|
import { DOMParser as PMDOMParser, DOMSerializer, Schema, type MarkType } from "prosemirror-model";
|
||||||
|
import { addColumnAfter, addRowAfter, deleteTable, goToNextCell, tableNodes, tableEditing } from "prosemirror-tables";
|
||||||
|
import { schema as basic } from "prosemirror-schema-basic";
|
||||||
|
import { liftListItem, sinkListItem, splitListItem, wrapInList } from "prosemirror-schema-list";
|
||||||
|
import { EditorState, type Command } from "prosemirror-state";
|
||||||
|
import { EditorView } from "prosemirror-view";
|
||||||
|
// Basisstile des Editors (Cursor, Auswahl) — gehören zum Paket.
|
||||||
|
import "prosemirror-view/style/prosemirror.css";
|
||||||
|
import { t } from "./messages";
|
||||||
|
|
||||||
|
/// Schema: Grundbausteine plus Listen und Tabellen — mehr braucht eine Notiz
|
||||||
|
/// nicht, und jeder zusätzliche Knoten wäre HTML, das wir beim Zurücklesen
|
||||||
|
/// wieder verstehen müssten.
|
||||||
|
const listNodes = {
|
||||||
|
ordered_list: {
|
||||||
|
content: "list_item+",
|
||||||
|
group: "block",
|
||||||
|
parseDOM: [{ tag: "ol" }],
|
||||||
|
toDOM: () => ["ol", 0] as const,
|
||||||
|
},
|
||||||
|
bullet_list: {
|
||||||
|
content: "list_item+",
|
||||||
|
group: "block",
|
||||||
|
parseDOM: [{ tag: "ul" }],
|
||||||
|
toDOM: () => ["ul", 0] as const,
|
||||||
|
},
|
||||||
|
list_item: {
|
||||||
|
content: "paragraph block*",
|
||||||
|
parseDOM: [{ tag: "li" }],
|
||||||
|
toDOM: () => ["li", 0] as const,
|
||||||
|
defining: true,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const schema = new Schema({
|
||||||
|
nodes: basic.spec.nodes
|
||||||
|
.append(listNodes)
|
||||||
|
.append(tableNodes({ tableGroup: "block", cellContent: "block+", cellAttributes: {} })),
|
||||||
|
marks: basic.spec.marks,
|
||||||
|
});
|
||||||
|
|
||||||
|
export interface HtmlEditor {
|
||||||
|
el: HTMLElement;
|
||||||
|
/// Aktueller Rumpf als HTML.
|
||||||
|
html(): string;
|
||||||
|
focus(): void;
|
||||||
|
destroy(): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Knopf der Werkzeugleiste; `run` liefert true, wenn der Befehl greift.
|
||||||
|
function button(label: string, title: string, run: () => void): HTMLElement {
|
||||||
|
const b = document.createElement("button");
|
||||||
|
b.type = "button";
|
||||||
|
b.className = "html-tool";
|
||||||
|
b.textContent = label;
|
||||||
|
b.title = title;
|
||||||
|
// mousedown statt click: sonst verliert die Auswahl im Editor den Fokus,
|
||||||
|
// bevor der Befehl auf ihr arbeiten kann.
|
||||||
|
b.addEventListener("mousedown", (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
run();
|
||||||
|
});
|
||||||
|
return b;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function initHtmlEditor(html: string): HtmlEditor {
|
||||||
|
const el = document.createElement("div");
|
||||||
|
el.className = "html-editor";
|
||||||
|
const bar = document.createElement("div");
|
||||||
|
bar.className = "html-toolbar";
|
||||||
|
const host = document.createElement("div");
|
||||||
|
host.className = "html-surface";
|
||||||
|
el.append(bar, host);
|
||||||
|
|
||||||
|
// Rumpf der Notiz ins Dokumentmodell lesen.
|
||||||
|
const source = document.createElement("div");
|
||||||
|
source.innerHTML = html;
|
||||||
|
const doc = PMDOMParser.fromSchema(schema).parse(source);
|
||||||
|
|
||||||
|
const view = new EditorView(host, {
|
||||||
|
state: EditorState.create({
|
||||||
|
doc,
|
||||||
|
plugins: [
|
||||||
|
history(),
|
||||||
|
keymap({
|
||||||
|
"Mod-z": undo,
|
||||||
|
"Mod-y": redo,
|
||||||
|
"Mod-Shift-z": redo,
|
||||||
|
"Mod-b": toggleMark(schema.marks.strong),
|
||||||
|
"Mod-i": toggleMark(schema.marks.em),
|
||||||
|
Enter: chainCommands(splitListItem(schema.nodes.list_item), baseKeymap.Enter),
|
||||||
|
Tab: goToNextCell(1),
|
||||||
|
"Shift-Tab": goToNextCell(-1),
|
||||||
|
}),
|
||||||
|
keymap(baseKeymap),
|
||||||
|
tableEditing(),
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const run = (cmd: Command) => {
|
||||||
|
cmd(view.state, view.dispatch, view);
|
||||||
|
view.focus();
|
||||||
|
};
|
||||||
|
const mark = (m: MarkType) => run(toggleMark(m));
|
||||||
|
const heading = (level: number) =>
|
||||||
|
run(setBlockType(schema.nodes.heading, { level }));
|
||||||
|
|
||||||
|
/// Tabelle mit zwei Zeilen und zwei Spalten einsetzen.
|
||||||
|
const insertTable: Command = (state, dispatch) => {
|
||||||
|
const { table, table_row, table_cell, paragraph } = schema.nodes;
|
||||||
|
const cell = () => table_cell.createAndFill()!;
|
||||||
|
const row = table_row.create(null, [cell(), cell()]);
|
||||||
|
const node = table.create(null, [row, row.copy(row.content)]);
|
||||||
|
if (dispatch) dispatch(state.tr.replaceSelectionWith(node).scrollIntoView());
|
||||||
|
void paragraph;
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
bar.append(
|
||||||
|
button("H1", t("html.h1"), () => heading(1)),
|
||||||
|
button("H2", t("html.h2"), () => heading(2)),
|
||||||
|
button("¶", t("html.paragraph"), () => run(setBlockType(schema.nodes.paragraph))),
|
||||||
|
button("B", t("html.bold"), () => mark(schema.marks.strong)),
|
||||||
|
button("I", t("html.italic"), () => mark(schema.marks.em)),
|
||||||
|
button("•", t("html.bulletList"), () => run(wrapInList(schema.nodes.bullet_list))),
|
||||||
|
button("1.", t("html.orderedList"), () => run(wrapInList(schema.nodes.ordered_list))),
|
||||||
|
button("→", t("html.indent"), () => run(sinkListItem(schema.nodes.list_item))),
|
||||||
|
button("←", t("html.outdent"), () => run(liftListItem(schema.nodes.list_item))),
|
||||||
|
button("⊞", t("html.table"), () => run(insertTable)),
|
||||||
|
button("+↓", t("html.rowAfter"), () => run(addRowAfter)),
|
||||||
|
button("+→", t("html.colAfter"), () => run(addColumnAfter)),
|
||||||
|
button("⌫", t("html.deleteTable"), () => run(deleteTable)),
|
||||||
|
button("🔗", t("html.link"), () => setLink()),
|
||||||
|
);
|
||||||
|
|
||||||
|
/// Link auf der Auswahl setzen oder entfernen.
|
||||||
|
function setLink() {
|
||||||
|
const { from, to } = view.state.selection;
|
||||||
|
if (from === to) return;
|
||||||
|
const has = view.state.doc.rangeHasMark(from, to, schema.marks.link);
|
||||||
|
if (has) {
|
||||||
|
run(toggleMark(schema.marks.link));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const href = window.prompt(t("html.linkPrompt"), "https://");
|
||||||
|
if (!href) return;
|
||||||
|
run(toggleMark(schema.marks.link, { href }));
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
el,
|
||||||
|
html() {
|
||||||
|
const fragment = DOMSerializer.fromSchema(schema).serializeFragment(
|
||||||
|
view.state.doc.content,
|
||||||
|
);
|
||||||
|
const out = document.createElement("div");
|
||||||
|
out.append(fragment);
|
||||||
|
return out.innerHTML;
|
||||||
|
},
|
||||||
|
focus: () => view.focus(),
|
||||||
|
destroy: () => view.destroy(),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -239,6 +239,23 @@ const de = {
|
|||||||
hits: "{count} Treffer {scope}",
|
hits: "{count} Treffer {scope}",
|
||||||
noHits: "Keine Treffer für {scope}",
|
noHits: "Keine Treffer für {scope}",
|
||||||
},
|
},
|
||||||
|
html: {
|
||||||
|
h1: "Überschrift 1",
|
||||||
|
h2: "Überschrift 2",
|
||||||
|
paragraph: "Absatz",
|
||||||
|
bold: "Fett",
|
||||||
|
italic: "Kursiv",
|
||||||
|
bulletList: "Aufzählung",
|
||||||
|
orderedList: "Nummerierte Liste",
|
||||||
|
indent: "Einrücken",
|
||||||
|
outdent: "Ausrücken",
|
||||||
|
table: "Tabelle einfügen",
|
||||||
|
rowAfter: "Zeile darunter",
|
||||||
|
colAfter: "Spalte rechts",
|
||||||
|
deleteTable: "Tabelle löschen",
|
||||||
|
link: "Link setzen/entfernen",
|
||||||
|
linkPrompt: "Adresse des Links",
|
||||||
|
},
|
||||||
wiki: {
|
wiki: {
|
||||||
archive: "Archiv",
|
archive: "Archiv",
|
||||||
docOne: "{count} Dokument",
|
docOne: "{count} Dokument",
|
||||||
@@ -261,6 +278,7 @@ const de = {
|
|||||||
deleteDoc: "Dokument löschen",
|
deleteDoc: "Dokument löschen",
|
||||||
newFolder: "Neuer Ordner",
|
newFolder: "Neuer Ordner",
|
||||||
newDoc: "Neues Dokument",
|
newDoc: "Neues Dokument",
|
||||||
|
newHtml: "Neue HTML-Notiz",
|
||||||
create: "Anlegen",
|
create: "Anlegen",
|
||||||
cancel: "Abbrechen",
|
cancel: "Abbrechen",
|
||||||
docName: "Name des Dokuments",
|
docName: "Name des Dokuments",
|
||||||
@@ -501,6 +519,23 @@ const en: typeof de = {
|
|||||||
hits: "{count} hits {scope}",
|
hits: "{count} hits {scope}",
|
||||||
noHits: "No hits for {scope}",
|
noHits: "No hits for {scope}",
|
||||||
},
|
},
|
||||||
|
html: {
|
||||||
|
h1: "Heading 1",
|
||||||
|
h2: "Heading 2",
|
||||||
|
paragraph: "Paragraph",
|
||||||
|
bold: "Bold",
|
||||||
|
italic: "Italic",
|
||||||
|
bulletList: "Bullet list",
|
||||||
|
orderedList: "Numbered list",
|
||||||
|
indent: "Indent",
|
||||||
|
outdent: "Outdent",
|
||||||
|
table: "Insert table",
|
||||||
|
rowAfter: "Row below",
|
||||||
|
colAfter: "Column right",
|
||||||
|
deleteTable: "Delete table",
|
||||||
|
link: "Set/remove link",
|
||||||
|
linkPrompt: "Link address",
|
||||||
|
},
|
||||||
wiki: {
|
wiki: {
|
||||||
archive: "Archive",
|
archive: "Archive",
|
||||||
docOne: "{count} document",
|
docOne: "{count} document",
|
||||||
@@ -523,6 +558,7 @@ const en: typeof de = {
|
|||||||
deleteDoc: "Delete document",
|
deleteDoc: "Delete document",
|
||||||
newFolder: "New folder",
|
newFolder: "New folder",
|
||||||
newDoc: "New document",
|
newDoc: "New document",
|
||||||
|
newHtml: "New HTML note",
|
||||||
create: "Create",
|
create: "Create",
|
||||||
cancel: "Cancel",
|
cancel: "Cancel",
|
||||||
docName: "Document name",
|
docName: "Document name",
|
||||||
|
|||||||
@@ -24,6 +24,12 @@ export const wikiTab: PanelTab = {
|
|||||||
void invoke(cmd, { project: ctx.project, ...args }).catch((e) =>
|
void invoke(cmd, { project: ctx.project, ...args }).catch((e) =>
|
||||||
ctx.toast(String(e)),
|
ctx.toast(String(e)),
|
||||||
);
|
);
|
||||||
|
// Anlegen liefert die ID der neuen Notiz zurück.
|
||||||
|
const create = (cmd: string, args: Record<string, string>) =>
|
||||||
|
invoke<string>(cmd, { project: ctx.project, ...args }).catch((e) => {
|
||||||
|
ctx.toast(String(e));
|
||||||
|
throw e;
|
||||||
|
});
|
||||||
return initWikiView(container, {
|
return initWikiView(container, {
|
||||||
autoStart: ctx.standalone,
|
autoStart: ctx.standalone,
|
||||||
readDoc: (id) => invoke("archive_read", { project: ctx.project, id }),
|
readDoc: (id) => invoke("archive_read", { project: ctx.project, id }),
|
||||||
@@ -38,7 +44,8 @@ export const wikiTab: PanelTab = {
|
|||||||
actions: {
|
actions: {
|
||||||
remove: (id) => run("archive_delete", { id }),
|
remove: (id) => run("archive_delete", { id }),
|
||||||
createFolder: (parent, name) => run("archive_create_folder", { parent, name }),
|
createFolder: (parent, name) => run("archive_create_folder", { parent, name }),
|
||||||
createDoc: (parent, name) => run("archive_create_doc", { parent, name }),
|
createDoc: (parent, name) => create("archive_create_doc", { parent, name }),
|
||||||
|
createHtml: (parent, name) => create("archive_create_html", { parent, name }),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -684,6 +684,76 @@
|
|||||||
.wiki-note-actions {
|
.wiki-note-actions {
|
||||||
visibility: visible;
|
visibility: visible;
|
||||||
}
|
}
|
||||||
|
/* WYSIWYG-Editor der HTML-Notizen (ProseMirror). */
|
||||||
|
.html-editor {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
.html-toolbar {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 4px;
|
||||||
|
background: var(--tile);
|
||||||
|
border: 1px solid var(--line-strong);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 6px;
|
||||||
|
}
|
||||||
|
.html-tool {
|
||||||
|
min-width: 32px;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
background: var(--surface);
|
||||||
|
color: var(--text);
|
||||||
|
border-radius: 5px;
|
||||||
|
padding: 5px 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
font:
|
||||||
|
600 14px/1.2 -apple-system,
|
||||||
|
system-ui,
|
||||||
|
sans-serif;
|
||||||
|
}
|
||||||
|
.html-tool:hover {
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
.html-surface .ProseMirror {
|
||||||
|
min-height: 55vh;
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--line-strong);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 14px 16px;
|
||||||
|
outline: none;
|
||||||
|
color: var(--text);
|
||||||
|
font:
|
||||||
|
400 17px/1.6 -apple-system,
|
||||||
|
system-ui,
|
||||||
|
sans-serif;
|
||||||
|
}
|
||||||
|
.html-surface .ProseMirror:focus {
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
.html-surface .ProseMirror table {
|
||||||
|
border-collapse: collapse;
|
||||||
|
width: 100%;
|
||||||
|
margin: 0 0 0.7em;
|
||||||
|
}
|
||||||
|
.html-surface .ProseMirror td,
|
||||||
|
.html-surface .ProseMirror th {
|
||||||
|
border: 1px solid var(--line-strong);
|
||||||
|
padding: 6px 8px;
|
||||||
|
vertical-align: top;
|
||||||
|
}
|
||||||
|
.html-surface .ProseMirror .selectedCell::after {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
background: var(--accent);
|
||||||
|
opacity: 0.18;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
.html-surface .ProseMirror td,
|
||||||
|
.html-surface .ProseMirror th {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
/* Markdown-Editor der Notiz (Bearbeiten im Archiv): Rohtext links,
|
/* Markdown-Editor der Notiz (Bearbeiten im Archiv): Rohtext links,
|
||||||
Live-Vorschau rechts. */
|
Live-Vorschau rechts. */
|
||||||
.wiki-edit-split {
|
.wiki-edit-split {
|
||||||
@@ -848,6 +918,16 @@
|
|||||||
.wiki-note-body a {
|
.wiki-note-body a {
|
||||||
color: var(--accent);
|
color: var(--accent);
|
||||||
}
|
}
|
||||||
|
.wiki-note-body table {
|
||||||
|
border-collapse: collapse;
|
||||||
|
margin: 0 0 0.7em;
|
||||||
|
}
|
||||||
|
.wiki-note-body td,
|
||||||
|
.wiki-note-body th {
|
||||||
|
border: 1px solid var(--line-strong);
|
||||||
|
padding: 6px 8px;
|
||||||
|
vertical-align: top;
|
||||||
|
}
|
||||||
.wiki-note-children {
|
.wiki-note-children {
|
||||||
margin-top: 26px;
|
margin-top: 26px;
|
||||||
background: var(--tile);
|
background: var(--tile);
|
||||||
|
|||||||
+1
-1
@@ -146,7 +146,7 @@ const archiveForm = initArchiveForm(
|
|||||||
{
|
{
|
||||||
// Ordner-Vorschläge aus dem Archiv; „Auf Platte legen" schreibt den
|
// Ordner-Vorschläge aus dem Archiv; „Auf Platte legen" schreibt den
|
||||||
// Entwurf an einen frei gewählten Pfad (ohne Archiv, ohne Frontmatter).
|
// Entwurf an einen frei gewählten Pfad (ohne Archiv, ohne Frontmatter).
|
||||||
folders: () => invoke<string[]>("archive_folders", { project }),
|
folders: () => invoke("archive_folders", { project }),
|
||||||
onSave: async () => {
|
onSave: async () => {
|
||||||
try {
|
try {
|
||||||
const { save } = await import("@tauri-apps/plugin-dialog");
|
const { save } = await import("@tauri-apps/plugin-dialog");
|
||||||
|
|||||||
+1
-1
@@ -318,7 +318,7 @@ const archiveForm = initArchiveForm(
|
|||||||
{
|
{
|
||||||
// Ordner-Vorschläge aus dem Archiv; „Auf Platte legen" schreibt den
|
// Ordner-Vorschläge aus dem Archiv; „Auf Platte legen" schreibt den
|
||||||
// Entwurf an einen frei gewählten Pfad (ohne Archiv, ohne Frontmatter).
|
// Entwurf an einen frei gewählten Pfad (ohne Archiv, ohne Frontmatter).
|
||||||
folders: () => invoke<string[]>("archive_folders", { project }),
|
folders: () => invoke("archive_folders", { project }),
|
||||||
onSave: async () => {
|
onSave: async () => {
|
||||||
try {
|
try {
|
||||||
const { save } = await import("@tauri-apps/plugin-dialog");
|
const { save } = await import("@tauri-apps/plugin-dialog");
|
||||||
|
|||||||
+92
-1
@@ -14,7 +14,8 @@ function setup(pending: string | null = null) {
|
|||||||
const actions = {
|
const actions = {
|
||||||
remove: vi.fn(),
|
remove: vi.fn(),
|
||||||
createFolder: vi.fn(),
|
createFolder: vi.fn(),
|
||||||
createDoc: vi.fn(),
|
createDoc: vi.fn(() => Promise.resolve("id-neu-doc")),
|
||||||
|
createHtml: vi.fn(() => Promise.resolve("id-neu-html")),
|
||||||
};
|
};
|
||||||
let pendingSelect = pending;
|
let pendingSelect = pending;
|
||||||
const view = initWikiView(document.getElementById("w")!, {
|
const view = initWikiView(document.getElementById("w")!, {
|
||||||
@@ -43,6 +44,7 @@ const doc = (relpath: string, name: string, extra: object = {}) => ({
|
|||||||
date: "2026-07-19",
|
date: "2026-07-19",
|
||||||
backlinks: 0,
|
backlinks: 0,
|
||||||
modified: "2026-07-24",
|
modified: "2026-07-24",
|
||||||
|
kind: "md",
|
||||||
...extra,
|
...extra,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -219,6 +221,95 @@ describe("initWikiView — Notiz-Ansicht", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("initWikiView — HTML-Notizen", () => {
|
||||||
|
const htmlPage = JSON.stringify({
|
||||||
|
kind: "page",
|
||||||
|
home: "/tmp/archiv",
|
||||||
|
tag: null,
|
||||||
|
total: 1,
|
||||||
|
tags: [],
|
||||||
|
folders: [
|
||||||
|
{ name: "", docs: [doc("seite.html", "seite", { kind: "html" })] },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
it("zeigt HTML ohne Markdown-Renderer und trägt ein eigenes Symbol", async () => {
|
||||||
|
document.body.innerHTML = `<div id="w"></div>`;
|
||||||
|
const readDoc = vi.fn(() => Promise.resolve("<p>Roher <b>Text</b></p>"));
|
||||||
|
const view = initWikiView(document.getElementById("w")!, {
|
||||||
|
autoStart: true,
|
||||||
|
readDoc,
|
||||||
|
writeDoc: vi.fn(() => Promise.resolve()),
|
||||||
|
setTitle: vi.fn(),
|
||||||
|
openWiki: vi.fn(),
|
||||||
|
actions: {
|
||||||
|
remove: vi.fn(),
|
||||||
|
createFolder: vi.fn(),
|
||||||
|
createDoc: vi.fn(() => Promise.resolve("")),
|
||||||
|
createHtml: vi.fn(() => Promise.resolve("")),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
view.set(htmlPage);
|
||||||
|
document.querySelector<HTMLElement>(".wiki-tree-doc")!.click();
|
||||||
|
await flush();
|
||||||
|
// Der Rumpf steht als Markup in der Ansicht — kein <p> aus Markdown.
|
||||||
|
expect(document.querySelector(".wiki-note-body b")!.textContent).toBe("Text");
|
||||||
|
expect(readDoc).toHaveBeenCalledWith("id-seite");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Kontextmenü legt HTML-Notizen unter dem Knoten an", () => {
|
||||||
|
const { view, actions } = setup();
|
||||||
|
view.set(mergedPage);
|
||||||
|
document
|
||||||
|
.querySelector<HTMLElement>(".wiki-tree summary")!
|
||||||
|
.dispatchEvent(new MouseEvent("contextmenu", { bubbles: true }));
|
||||||
|
const items = [...document.querySelectorAll<HTMLElement>(".wiki-menu-item")];
|
||||||
|
items.find((i) => i.textContent === "Neue HTML-Notiz")!.click();
|
||||||
|
const input = document.querySelector<HTMLInputElement>(".wiki-form input")!;
|
||||||
|
input.value = "Seite";
|
||||||
|
input.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true }));
|
||||||
|
expect(actions.createHtml).toHaveBeenCalledWith("id-konzepte", "Seite");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("initWikiView — Anlegen öffnet den Editor", () => {
|
||||||
|
it("wählt die neue Notiz aus und zeigt den Editor", async () => {
|
||||||
|
const { view, actions } = setup();
|
||||||
|
view.set(page);
|
||||||
|
document
|
||||||
|
.querySelector<HTMLElement>(".wiki-tree summary")!
|
||||||
|
.dispatchEvent(new MouseEvent("contextmenu", { bubbles: true }));
|
||||||
|
const items = [...document.querySelectorAll<HTMLElement>(".wiki-menu-item")];
|
||||||
|
items.find((i) => i.textContent === "Neues Dokument")!.click();
|
||||||
|
const input = document.querySelector<HTMLInputElement>(".wiki-form input")!;
|
||||||
|
input.value = "Frisch";
|
||||||
|
input.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true }));
|
||||||
|
expect(actions.createDoc).toHaveBeenCalledWith("id-konzepte", "Frisch");
|
||||||
|
await flush();
|
||||||
|
|
||||||
|
// Die Übersicht kommt nach; erst dann greift die Vormerkung.
|
||||||
|
expect(document.querySelector(".wiki-note-editor")).toBeNull();
|
||||||
|
view.set(
|
||||||
|
JSON.stringify({
|
||||||
|
kind: "page",
|
||||||
|
home: "/tmp/archiv",
|
||||||
|
tag: null,
|
||||||
|
total: 1,
|
||||||
|
tags: [],
|
||||||
|
folders: [
|
||||||
|
{
|
||||||
|
name: "",
|
||||||
|
docs: [doc("frisch.md", "frisch", { id: "id-neu-doc", title: "Frisch" })],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
await flush();
|
||||||
|
expect(document.querySelector(".wiki-note-title")!.textContent).toBe("Frisch");
|
||||||
|
expect(document.querySelector(".wiki-note-editor")).not.toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("initWikiView — Zurück", () => {
|
describe("initWikiView — Zurück", () => {
|
||||||
it("Zurück-Knopf führt zur vorherigen Auswahl", async () => {
|
it("Zurück-Knopf führt zur vorherigen Auswahl", async () => {
|
||||||
const { view } = setup();
|
const { view } = setup();
|
||||||
|
|||||||
+88
-33
@@ -6,6 +6,7 @@
|
|||||||
/// Inhalt die Kindliste. Titel, Beschreibungen und Pfade sind Fremdtext und
|
/// Inhalt die Kindliste. Titel, Beschreibungen und Pfade sind Fremdtext und
|
||||||
/// gehen nie durch innerHTML; der Markdown-Body läuft durch renderMarkdown.
|
/// gehen nie durch innerHTML; der Markdown-Body läuft durch renderMarkdown.
|
||||||
|
|
||||||
|
import { initHtmlEditor } from "./html-editor";
|
||||||
import { renderMarkdown } from "./markdown";
|
import { renderMarkdown } from "./markdown";
|
||||||
import { t } from "./messages";
|
import { t } from "./messages";
|
||||||
import { linkWikiRefs } from "./panel-view";
|
import { linkWikiRefs } from "./panel-view";
|
||||||
@@ -24,6 +25,8 @@ interface DocEntry {
|
|||||||
backlinks: number;
|
backlinks: number;
|
||||||
/// Letzte Änderung (Datei-mtime, YYYY-MM-DD).
|
/// Letzte Änderung (Datei-mtime, YYYY-MM-DD).
|
||||||
modified: string;
|
modified: string;
|
||||||
|
/// Notiz-Typ: `md` (Markdown) oder `html`.
|
||||||
|
kind: "md" | "html";
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Folder {
|
interface Folder {
|
||||||
@@ -46,7 +49,9 @@ export interface WikiActions {
|
|||||||
remove(id: string): void;
|
remove(id: string): void;
|
||||||
/// Neues Kind unterhalb des Knotens (ID; "" = Wurzel).
|
/// Neues Kind unterhalb des Knotens (ID; "" = Wurzel).
|
||||||
createFolder(parent: string, name: string): void;
|
createFolder(parent: string, name: string): void;
|
||||||
createDoc(parent: string, name: string): void;
|
/// Liefern die ID der neuen Notiz — die Ansicht öffnet sie im Editor.
|
||||||
|
createDoc(parent: string, name: string): Promise<string>;
|
||||||
|
createHtml(parent: string, name: string): Promise<string>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface WikiCallbacks {
|
export interface WikiCallbacks {
|
||||||
@@ -147,6 +152,25 @@ export function initWikiView(container: HTMLElement, cb: WikiCallbacks): WikiVie
|
|||||||
const history: string[] = [];
|
const history: string[] = [];
|
||||||
/// Bearbeitungsmodus der aktuellen Notiz (Editor statt Anzeige).
|
/// Bearbeitungsmodus der aktuellen Notiz (Editor statt Anzeige).
|
||||||
let editing = false;
|
let editing = false;
|
||||||
|
/// Eben angelegte Notiz: sobald sie in der Übersicht auftaucht, wird sie
|
||||||
|
/// ausgewählt und der Editor geöffnet.
|
||||||
|
let pendingEdit: string | null = null;
|
||||||
|
|
||||||
|
/// Neue Notiz übernehmen — je nachdem, ob die frische Übersicht schon da
|
||||||
|
/// ist, sofort oder beim nächsten Puffer-Update.
|
||||||
|
function openNew(id: string) {
|
||||||
|
pendingEdit = id;
|
||||||
|
if (findDoc(id)) applyPendingEdit();
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyPendingEdit() {
|
||||||
|
if (!pendingEdit || !findDoc(pendingEdit)) return;
|
||||||
|
const id = pendingEdit;
|
||||||
|
pendingEdit = null;
|
||||||
|
select(id);
|
||||||
|
editing = true;
|
||||||
|
renderMain();
|
||||||
|
}
|
||||||
|
|
||||||
/// Ist die ID ein Blatt (Notiz ohne Kinder)?
|
/// Ist die ID ein Blatt (Notiz ohne Kinder)?
|
||||||
const isLeaf = (id: string) => !!id && !nodeById(id);
|
const isLeaf = (id: string) => !!id && !nodeById(id);
|
||||||
@@ -239,13 +263,16 @@ export function initWikiView(container: HTMLElement, cb: WikiCallbacks): WikiVie
|
|||||||
|
|
||||||
/// Typ-Symbol im Baum (Stroke-SVG wie die übrigen App-Icons): neutraler
|
/// Typ-Symbol im Baum (Stroke-SVG wie die übrigen App-Icons): neutraler
|
||||||
/// Knoten-Kreis für Ordner-Notizen, Blatt für Dokumente.
|
/// Knoten-Kreis für Ordner-Notizen, Blatt für Dokumente.
|
||||||
function typeIcon(kind: "node" | "doc"): HTMLElement {
|
function typeIcon(kind: "node" | "doc" | "html"): HTMLElement {
|
||||||
const span = document.createElement("span");
|
const span = document.createElement("span");
|
||||||
span.className = "wiki-tree-icon";
|
span.className = "wiki-tree-icon";
|
||||||
span.innerHTML =
|
const shapes = {
|
||||||
kind === "node"
|
node: `<circle cx="8" cy="8" r="4.2"/>`,
|
||||||
? `<svg width="17" height="17" viewBox="0 0 16 16"><circle cx="8" cy="8" r="4.2"/></svg>`
|
doc: `<path d="M4 1.5h5.5L12.5 5v9a.9.9 0 0 1-.9.9H4a.9.9 0 0 1-.9-.9V2.4a.9.9 0 0 1 .9-.9z"/><path d="M9.5 1.5V5H13"/><path d="M5.8 8h4.4M5.8 10.5h4.4"/>`,
|
||||||
: `<svg width="17" height="17" viewBox="0 0 16 16"><path d="M4 1.5h5.5L12.5 5v9a.9.9 0 0 1-.9.9H4a.9.9 0 0 1-.9-.9V2.4a.9.9 0 0 1 .9-.9z"/><path d="M9.5 1.5V5H13"/><path d="M5.8 8h4.4M5.8 10.5h4.4"/></svg>`;
|
// HTML-Notiz: dasselbe Blatt, spitze Klammern statt Textzeilen.
|
||||||
|
html: `<path d="M4 1.5h5.5L12.5 5v9a.9.9 0 0 1-.9.9H4a.9.9 0 0 1-.9-.9V2.4a.9.9 0 0 1 .9-.9z"/><path d="M9.5 1.5V5H13"/><path d="M6.6 8.2 5.2 9.6l1.4 1.4M9.4 8.2l1.4 1.4-1.4 1.4"/>`,
|
||||||
|
};
|
||||||
|
span.innerHTML = `<svg width="17" height="17" viewBox="0 0 16 16">${shapes[kind]}</svg>`;
|
||||||
return span;
|
return span;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -286,6 +313,7 @@ export function initWikiView(container: HTMLElement, cb: WikiCallbacks): WikiVie
|
|||||||
const id = node.content?.id ?? "";
|
const id = node.content?.id ?? "";
|
||||||
openMenu(e.clientX, e.clientY, [
|
openMenu(e.clientX, e.clientY, [
|
||||||
{ label: t("wiki.newDoc"), run: () => newDocForm(id) },
|
{ label: t("wiki.newDoc"), run: () => newDocForm(id) },
|
||||||
|
{ label: t("wiki.newHtml"), run: () => newHtmlForm(id) },
|
||||||
{ label: t("wiki.newFolder"), run: () => newFolderForm(id) },
|
{ label: t("wiki.newFolder"), run: () => newFolderForm(id) },
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
@@ -301,7 +329,7 @@ export function initWikiView(container: HTMLElement, cb: WikiCallbacks): WikiVie
|
|||||||
const label = document.createElement("span");
|
const label = document.createElement("span");
|
||||||
label.className = "wiki-tree-name";
|
label.className = "wiki-tree-name";
|
||||||
label.textContent = doc.title;
|
label.textContent = doc.title;
|
||||||
row.append(typeIcon("doc"), label);
|
row.append(typeIcon(doc.kind === "html" ? "html" : "doc"), label);
|
||||||
row.addEventListener("click", () => select(doc.id));
|
row.addEventListener("click", () => select(doc.id));
|
||||||
row.addEventListener("contextmenu", (e) => {
|
row.addEventListener("contextmenu", (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -352,6 +380,7 @@ export function initWikiView(container: HTMLElement, cb: WikiCallbacks): WikiVie
|
|||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
openMenu(e.clientX, e.clientY, [
|
openMenu(e.clientX, e.clientY, [
|
||||||
{ label: t("wiki.newDoc"), run: () => newDocForm("") },
|
{ label: t("wiki.newDoc"), run: () => newDocForm("") },
|
||||||
|
{ label: t("wiki.newHtml"), run: () => newHtmlForm("") },
|
||||||
{ label: t("wiki.newFolder"), run: () => newFolderForm("") },
|
{ label: t("wiki.newFolder"), run: () => newFolderForm("") },
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
@@ -363,9 +392,15 @@ export function initWikiView(container: HTMLElement, cb: WikiCallbacks): WikiVie
|
|||||||
// ---------- Anlege-Formular (Ordner/Dokument) ----------
|
// ---------- Anlege-Formular (Ordner/Dokument) ----------
|
||||||
|
|
||||||
function newDocForm(parent: string) {
|
function newDocForm(parent: string) {
|
||||||
openForm(t("wiki.newDoc"), t("wiki.docName"), "", (v) =>
|
openForm(t("wiki.newDoc"), t("wiki.docName"), "", (v) => {
|
||||||
cb.actions.createDoc(parent, v),
|
void cb.actions.createDoc(parent, v).then(openNew);
|
||||||
);
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function newHtmlForm(parent: string) {
|
||||||
|
openForm(t("wiki.newHtml"), t("wiki.docName"), "", (v) => {
|
||||||
|
void cb.actions.createHtml(parent, v).then(openNew);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function newFolderForm(parent: string) {
|
function newFolderForm(parent: string) {
|
||||||
@@ -533,7 +568,10 @@ export function initWikiView(container: HTMLElement, cb: WikiCallbacks): WikiVie
|
|||||||
cb.readDoc(doc.id).then(
|
cb.readDoc(doc.id).then(
|
||||||
(text) => {
|
(text) => {
|
||||||
if (selected !== doc.id) return;
|
if (selected !== doc.id) return;
|
||||||
body.innerHTML = renderMarkdown(text);
|
// HTML-Notizen sind bereits Markup; Markdown läuft durch den
|
||||||
|
// Renderer. Beides stammt aus dem eigenen Archiv und geht durch
|
||||||
|
// dieselbe Wikilink-Verdrahtung.
|
||||||
|
body.innerHTML = doc.kind === "html" ? text : renderMarkdown(text);
|
||||||
linkWikiRefs(body, followWikiLink);
|
linkWikiRefs(body, followWikiLink);
|
||||||
},
|
},
|
||||||
(e) => {
|
(e) => {
|
||||||
@@ -554,6 +592,33 @@ export function initWikiView(container: HTMLElement, cb: WikiCallbacks): WikiVie
|
|||||||
const err = document.createElement("div");
|
const err = document.createElement("div");
|
||||||
err.className = "wiki-note-error";
|
err.className = "wiki-note-error";
|
||||||
err.hidden = true;
|
err.hidden = true;
|
||||||
|
const fail = (e: unknown) => {
|
||||||
|
err.hidden = false;
|
||||||
|
err.textContent = String(e);
|
||||||
|
};
|
||||||
|
|
||||||
|
// HTML-Notizen: WYSIWYG auf ProseMirror, das Format bleibt HTML.
|
||||||
|
if (doc.kind === "html") {
|
||||||
|
let editor: { html(): string; destroy(): void } | null = null;
|
||||||
|
cb.readDoc(doc.id).then((text) => {
|
||||||
|
if (selected !== doc.id || !editing) return;
|
||||||
|
const ed = initHtmlEditor(text);
|
||||||
|
editor = ed;
|
||||||
|
box.append(ed.el);
|
||||||
|
ed.focus();
|
||||||
|
}, fail);
|
||||||
|
return {
|
||||||
|
el: box,
|
||||||
|
save: () => {
|
||||||
|
if (!editor) return;
|
||||||
|
cb.writeDoc(doc.id, editor.html()).then(() => {
|
||||||
|
editor?.destroy();
|
||||||
|
editing = false;
|
||||||
|
renderMain();
|
||||||
|
}, fail);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
const split = document.createElement("div");
|
const split = document.createElement("div");
|
||||||
split.className = "wiki-edit-split";
|
split.className = "wiki-edit-split";
|
||||||
const area = document.createElement("textarea");
|
const area = document.createElement("textarea");
|
||||||
@@ -562,32 +627,21 @@ export function initWikiView(container: HTMLElement, cb: WikiCallbacks): WikiVie
|
|||||||
const preview = document.createElement("div");
|
const preview = document.createElement("div");
|
||||||
preview.className = "wiki-note-body wiki-edit-preview";
|
preview.className = "wiki-note-body wiki-edit-preview";
|
||||||
const draw = () => {
|
const draw = () => {
|
||||||
preview.innerHTML = renderMarkdown(area.value);
|
preview.innerHTML =
|
||||||
|
doc.kind === "html" ? area.value : renderMarkdown(area.value);
|
||||||
linkWikiRefs(preview, followWikiLink);
|
linkWikiRefs(preview, followWikiLink);
|
||||||
};
|
};
|
||||||
cb.readDoc(doc.id).then(
|
cb.readDoc(doc.id).then((text) => {
|
||||||
(text) => {
|
area.value = text;
|
||||||
area.value = text;
|
draw();
|
||||||
draw();
|
area.focus();
|
||||||
area.focus();
|
}, fail);
|
||||||
},
|
|
||||||
(e) => {
|
|
||||||
err.hidden = false;
|
|
||||||
err.textContent = String(e);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
area.addEventListener("input", draw);
|
area.addEventListener("input", draw);
|
||||||
const save = () => {
|
const save = () => {
|
||||||
cb.writeDoc(doc.id, area.value).then(
|
cb.writeDoc(doc.id, area.value).then(() => {
|
||||||
() => {
|
editing = false;
|
||||||
editing = false;
|
renderMain();
|
||||||
renderMain();
|
}, fail);
|
||||||
},
|
|
||||||
(e) => {
|
|
||||||
err.hidden = false;
|
|
||||||
err.textContent = String(e);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
area.addEventListener("keydown", (e) => {
|
area.addEventListener("keydown", (e) => {
|
||||||
if ((e.ctrlKey || e.metaKey) && e.key === "s") {
|
if ((e.ctrlKey || e.metaKey) && e.key === "s") {
|
||||||
@@ -801,6 +855,7 @@ export function initWikiView(container: HTMLElement, cb: WikiCallbacks): WikiVie
|
|||||||
selected = "";
|
selected = "";
|
||||||
}
|
}
|
||||||
render();
|
render();
|
||||||
|
applyPendingEdit();
|
||||||
},
|
},
|
||||||
empty: () => !loaded,
|
empty: () => !loaded,
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user