Compare commits
7 Commits
2d9a1e64f9
...
0.1.2
| Author | SHA1 | Date | |
|---|---|---|---|
| 61fb56fa14 | |||
| 8039a3545d | |||
| fe0476218d | |||
| 1220ce1bb0 | |||
| 4bb2c6cc77 | |||
| 2841881a01 | |||
| 8b0f128313 |
@@ -1,2 +1,10 @@
|
||||
src-tauri/target/
|
||||
src-tauri/gen/
|
||||
|
||||
# Artefacts de build Flatpak
|
||||
flatpak/.build/
|
||||
flatpak/.repo/
|
||||
flatpak/.flatpak-builder/
|
||||
flatpak/pena.deb
|
||||
flatpak/icon.png
|
||||
flatpak/*.flatpak
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# Construit un paquet Flatpak de Pena à partir du .deb généré par Tauri.
|
||||
#
|
||||
# Étapes :
|
||||
# 1. `cargo tauri build --bundles deb` -> produit le .deb autonome
|
||||
# 2. Copie du .deb à côté du manifeste sous le nom `pena.deb`
|
||||
# 3. `flatpak-builder` construit le bundle dans la sandbox GNOME
|
||||
# 4. Installation locale (--user) ET export du fichier pena.flatpak
|
||||
#
|
||||
# Le fichier `pena.flatpak` est TOUJOURS exporté à côté du script.
|
||||
#
|
||||
# Usage :
|
||||
# ./build-flatpak.sh # build + installation (--user) + pena.flatpak
|
||||
# ./build-flatpak.sh --no-install # build + pena.flatpak seul (pas d'installation)
|
||||
# ./build-flatpak.sh --no-deb # réutilise le .deb déjà présent (build rapide)
|
||||
#
|
||||
set -euo pipefail
|
||||
|
||||
APP_ID="com.pena.app"
|
||||
RUNTIME_VERSION="48"
|
||||
RUST_CONTAINER="ubuntu-rust" # distrobox contenant cargo-tauri + libs GTK/WebKit
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
PROJECT_DIR="$(dirname "$SCRIPT_DIR")" # .../Pena-tauri
|
||||
MANIFEST="$SCRIPT_DIR/${APP_ID}.yml"
|
||||
BUILD_DIR="$SCRIPT_DIR/.build" # arbre de build flatpak-builder
|
||||
REPO_DIR="$SCRIPT_DIR/.repo" # dépôt ostree temporaire
|
||||
DEB_TARGET="$SCRIPT_DIR/pena.deb"
|
||||
TAURI_CONF="$PROJECT_DIR/src-tauri/tauri.conf.json"
|
||||
METAINFO="$SCRIPT_DIR/${APP_ID}.metainfo.xml"
|
||||
|
||||
DO_INSTALL=1
|
||||
BUILD_DEB=1
|
||||
for arg in "$@"; do
|
||||
case "$arg" in
|
||||
--no-install|--bundle) DO_INSTALL=0 ;; # --bundle : alias rétrocompatible
|
||||
--no-deb) BUILD_DEB=0 ;;
|
||||
-h|--help) sed -n '2,17p' "$0"; exit 0 ;;
|
||||
*) echo "Argument inconnu : $arg" >&2; exit 1 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
# --- Vérification des outils -------------------------------------------------
|
||||
need() { command -v "$1" >/dev/null 2>&1 || { echo "Outil manquant : $1" >&2; exit 1; }; }
|
||||
need flatpak
|
||||
|
||||
# flatpak-builder : commande native, sinon le Flatpak org.flatpak.Builder
|
||||
# (cas des distributions atomiques type Bazzite/Silverblue).
|
||||
if command -v flatpak-builder >/dev/null 2>&1; then
|
||||
FLATPAK_BUILDER=(flatpak-builder)
|
||||
elif flatpak info org.flatpak.Builder >/dev/null 2>&1; then
|
||||
FLATPAK_BUILDER=(flatpak run org.flatpak.Builder)
|
||||
else
|
||||
echo "flatpak-builder est requis. Installez-le :" >&2
|
||||
echo " flatpak install -y flathub org.flatpak.Builder" >&2
|
||||
echo " ou sur Fedora classique : sudo dnf install flatpak-builder" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# --- Remote flathub (scope --user) -------------------------------------------
|
||||
# flatpak-builder est lancé en --user et installe les dépendances (runtime/SDK
|
||||
# via --install-deps-from=flathub) dans cette même installation. Le remote
|
||||
# flathub doit donc exister au niveau --user, ce qui n'est pas le cas par
|
||||
# défaut sur les distributions atomiques (où flathub est un remote système).
|
||||
echo ">> Vérification du remote flathub (--user)…"
|
||||
flatpak remote-add --if-not-exists --user flathub \
|
||||
https://flathub.org/repo/flathub.flatpakrepo
|
||||
|
||||
# --- 0. Version : source de vérité = tauri.conf.json -------------------------
|
||||
# On lit la version applicative depuis tauri.conf.json et on la répercute dans
|
||||
# le metainfo AppStream (release) afin que le Flatpak porte toujours la
|
||||
# dernière version déclarée dans les sources.
|
||||
need jq
|
||||
VERSION="$(jq -r '.version' "$TAURI_CONF")"
|
||||
if [[ -z "$VERSION" || "$VERSION" == "null" ]]; then
|
||||
echo "Impossible de lire la version dans $TAURI_CONF" >&2
|
||||
exit 1
|
||||
fi
|
||||
TODAY="$(date -u +%Y-%m-%d)"
|
||||
echo ">> Version applicative : $VERSION (build du $TODAY)"
|
||||
|
||||
# Met à jour (ou insère) l'entrée <release> du metainfo pour cette version.
|
||||
if grep -q "version=\"$VERSION\"" "$METAINFO"; then
|
||||
echo ">> Release $VERSION déjà présente dans le metainfo."
|
||||
else
|
||||
echo ">> Ajout de la release $VERSION au metainfo…"
|
||||
sed -i \
|
||||
"s#<releases>#<releases>\n <release version=\"$VERSION\" date=\"$TODAY\"/>#" \
|
||||
"$METAINFO"
|
||||
fi
|
||||
|
||||
# --- 1. Build du .deb via Tauri ---------------------------------------------
|
||||
if [[ "$BUILD_DEB" -eq 1 ]]; then
|
||||
# La compilation Tauri exige les libs de dev GTK/WebKit, absentes de l'hôte
|
||||
# (distribution atomique). On build donc dans le distrobox `$RUST_CONTAINER`,
|
||||
# qui embarque cargo-tauri et ces libs. flatpak-builder reste sur l'hôte.
|
||||
need distrobox
|
||||
echo ">> Build du paquet .deb dans le distrobox $RUST_CONTAINER…"
|
||||
distrobox enter "$RUST_CONTAINER" -- \
|
||||
bash -lc "cd '$PROJECT_DIR' && cargo tauri build --bundles deb"
|
||||
fi
|
||||
|
||||
DEB_SRC="$(find "$PROJECT_DIR/src-tauri/target/release/bundle/deb" -name '*.deb' 2>/dev/null | sort | tail -n1 || true)"
|
||||
if [[ -z "$DEB_SRC" ]]; then
|
||||
echo "Aucun .deb trouvé. Lancez le script sans --no-deb." >&2
|
||||
exit 1
|
||||
fi
|
||||
echo ">> Utilisation du .deb : $DEB_SRC"
|
||||
cp -f "$DEB_SRC" "$DEB_TARGET"
|
||||
|
||||
# Icône installée dans le Flatpak (128x128) depuis les sources du projet.
|
||||
cp -f "$PROJECT_DIR/src-tauri/icons/128x128.png" "$SCRIPT_DIR/icon.png"
|
||||
|
||||
# --- 2. Build Flatpak --------------------------------------------------------
|
||||
# Construit l'app dans un dépôt OSTree local ($REPO_DIR) ; les dépendances
|
||||
# manquantes (runtime/SDK) sont récupérées depuis flathub.
|
||||
echo ">> Construction du Flatpak…"
|
||||
rm -rf "$BUILD_DIR"
|
||||
FB_ARGS=(--force-clean --user --install-deps-from=flathub --repo="$REPO_DIR")
|
||||
if [[ "$DO_INSTALL" -eq 1 ]]; then
|
||||
FB_ARGS+=(--install)
|
||||
fi
|
||||
"${FLATPAK_BUILDER[@]}" "${FB_ARGS[@]}" "$BUILD_DIR" "$MANIFEST"
|
||||
|
||||
# --- 3. Export du fichier .flatpak (toujours) --------------------------------
|
||||
# Le bundle autonome est systématiquement exporté à côté du manifeste.
|
||||
OUT="$SCRIPT_DIR/pena.flatpak"
|
||||
echo ">> Export du bundle : $OUT"
|
||||
flatpak build-bundle "$REPO_DIR" "$OUT" "$APP_ID" \
|
||||
--runtime-repo=https://flathub.org/repo/flathub.flatpakrepo
|
||||
|
||||
echo ">> Nettoyage du dépôt OSTree temporaire…"
|
||||
rm -rf "$REPO_DIR"
|
||||
|
||||
if [[ "$DO_INSTALL" -eq 1 ]]; then
|
||||
echo ">> Pena $VERSION installé (--user) et bundle exporté : $OUT"
|
||||
echo ">> Lancer avec : flatpak run $APP_ID"
|
||||
else
|
||||
echo ">> Bundle exporté : $OUT"
|
||||
echo ">> Installer avec : flatpak install --user $OUT"
|
||||
fi
|
||||
|
||||
echo ">> Terminé."
|
||||
@@ -0,0 +1,25 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<component type="desktop-application">
|
||||
<id>com.pena.app</id>
|
||||
<metadata_license>CC0-1.0</metadata_license>
|
||||
<project_license>MIT</project_license>
|
||||
<name>Pena</name>
|
||||
<summary>Lecteur de fichiers Markdown</summary>
|
||||
<description>
|
||||
<p>
|
||||
Pena est une application de bureau pour lire des fichiers Markdown.
|
||||
La conversion Markdown vers HTML est réalisée côté Rust (comrak), avec
|
||||
coloration syntaxique des blocs de code et plusieurs thèmes de lecture.
|
||||
</p>
|
||||
</description>
|
||||
<launchable type="desktop-id">com.pena.app.desktop</launchable>
|
||||
<categories>
|
||||
<category>Utility</category>
|
||||
<category>TextEditor</category>
|
||||
</categories>
|
||||
<content_rating type="oars-1.1"/>
|
||||
<releases>
|
||||
<release version="0.1.2" date="2026-07-05"/>
|
||||
<release version="0.1.0" date="2026-06-23"/>
|
||||
</releases>
|
||||
</component>
|
||||
@@ -0,0 +1,49 @@
|
||||
id: com.pena.app
|
||||
runtime: org.gnome.Platform
|
||||
runtime-version: '48'
|
||||
sdk: org.gnome.Sdk
|
||||
command: pena
|
||||
|
||||
finish-args:
|
||||
# Affichage
|
||||
- --socket=wayland
|
||||
- --socket=fallback-x11
|
||||
- --device=dri
|
||||
- --share=ipc
|
||||
# Lecture des fichiers Markdown ouverts par l'utilisateur
|
||||
- --filesystem=home
|
||||
|
||||
modules:
|
||||
- name: pena
|
||||
buildsystem: simple
|
||||
sources:
|
||||
- type: file
|
||||
path: pena.deb
|
||||
- type: file
|
||||
path: com.pena.app.metainfo.xml
|
||||
- type: file
|
||||
path: icon.png
|
||||
build-commands:
|
||||
- |
|
||||
set -eux
|
||||
# Extraction du .deb produit par `cargo tauri build`
|
||||
ar -x pena.deb
|
||||
tar -xf data.tar.gz
|
||||
|
||||
# Binaire principal -> /app/bin/pena (correspond à `command:`)
|
||||
bin="$(find usr/bin -maxdepth 1 -type f | head -n1)"
|
||||
install -Dm755 "$bin" /app/bin/pena
|
||||
|
||||
# Fichier .desktop : renommé en <app-id>.desktop (exigence Flatpak)
|
||||
desktop="$(find usr/share/applications -name '*.desktop' | head -n1)"
|
||||
install -Dm644 "$desktop" /app/share/applications/com.pena.app.desktop
|
||||
sed -i \
|
||||
-e 's/^Exec=.*/Exec=pena/' \
|
||||
-e 's/^Icon=.*/Icon=com.pena.app/' \
|
||||
/app/share/applications/com.pena.app.desktop
|
||||
|
||||
# Icône (128x128) fournie séparément à côté du manifeste.
|
||||
install -Dm644 icon.png /app/share/icons/hicolor/128x128/apps/com.pena.app.png
|
||||
|
||||
# Métadonnées AppStream
|
||||
install -Dm644 com.pena.app.metainfo.xml /app/share/metainfo/com.pena.app.metainfo.xml
|
||||
|
After Width: | Height: | Size: 6.9 KiB |
@@ -2445,7 +2445,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pena-taury"
|
||||
version = "0.1.0"
|
||||
version = "0.1.2"
|
||||
dependencies = [
|
||||
"comrak",
|
||||
"notify",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "pena-taury"
|
||||
version = "0.1.0"
|
||||
version = "0.1.2"
|
||||
edition = "2021"
|
||||
|
||||
[lib]
|
||||
@@ -17,5 +17,5 @@ tauri-plugin-dialog = "2"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
comrak = { version = "0.28", features = ["syntect"] }
|
||||
syntect = "5"
|
||||
syntect = { version = "5", features = ["yaml-load"] }
|
||||
notify = "7"
|
||||
|
||||
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 32 KiB |
|
After Width: | Height: | Size: 1.8 KiB |
|
After Width: | Height: | Size: 4.9 KiB |
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 38 KiB |
|
After Width: | Height: | Size: 1.6 KiB |
|
After Width: | Height: | Size: 42 KiB |
|
After Width: | Height: | Size: 2.9 KiB |
|
After Width: | Height: | Size: 5.7 KiB |
|
After Width: | Height: | Size: 7.8 KiB |
|
After Width: | Height: | Size: 3.5 KiB |
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
|
||||
<background android:drawable="@color/ic_launcher_background"/>
|
||||
</adaptive-icon>
|
||||
|
After Width: | Height: | Size: 2.8 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 2.9 KiB |
|
After Width: | Height: | Size: 2.6 KiB |
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 2.8 KiB |
|
After Width: | Height: | Size: 7.3 KiB |
|
After Width: | Height: | Size: 26 KiB |
|
After Width: | Height: | Size: 7.9 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 45 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 69 KiB |
|
After Width: | Height: | Size: 21 KiB |
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<color name="ic_launcher_background">#fff</color>
|
||||
</resources>
|
||||
|
After Width: | Height: | Size: 44 KiB |
|
Before Width: | Height: | Size: 105 B After Width: | Height: | Size: 86 KiB |
|
After Width: | Height: | Size: 928 B |
|
After Width: | Height: | Size: 2.5 KiB |
|
After Width: | Height: | Size: 2.5 KiB |
|
After Width: | Height: | Size: 4.4 KiB |
|
After Width: | Height: | Size: 1.5 KiB |
|
After Width: | Height: | Size: 4.2 KiB |
|
After Width: | Height: | Size: 4.2 KiB |
|
After Width: | Height: | Size: 7.6 KiB |
|
After Width: | Height: | Size: 2.5 KiB |
|
After Width: | Height: | Size: 6.7 KiB |
|
After Width: | Height: | Size: 6.7 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 253 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 6.2 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 18 KiB |
@@ -102,13 +102,11 @@ body {
|
||||
.content pre:not(.syntax-highlighting) {
|
||||
background: #0f172a;
|
||||
border: 1px solid #1e293b;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
/* Bloc de code avec coloration syntaxique */
|
||||
.content .syntax-highlighting {
|
||||
border: 1px solid #1e293b;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
/* Texte dans un bloc sans coloration */
|
||||
|
||||
@@ -2,15 +2,30 @@ use comrak::plugins::syntect::SyntectAdapterBuilder;
|
||||
use comrak::{markdown_to_html_with_plugins, Options, Plugins};
|
||||
use syntect::highlighting::ThemeSet;
|
||||
use syntect::html::{css_for_theme_with_class_style, ClassStyle};
|
||||
use syntect::parsing::SyntaxDefinition;
|
||||
|
||||
use crate::domain::MarkdownRenderer;
|
||||
|
||||
const TYPESCRIPT_SYNTAX: &str =
|
||||
include_str!("../../resources/syntaxes/TypeScript.sublime-syntax");
|
||||
|
||||
fn build_syntax_set() -> syntect::parsing::SyntaxSet {
|
||||
let mut builder = syntect::parsing::SyntaxSet::load_defaults_nonewlines().into_builder();
|
||||
if let Ok(def) = SyntaxDefinition::load_from_str(TYPESCRIPT_SYNTAX, true, None) {
|
||||
builder.add(def);
|
||||
}
|
||||
builder.build()
|
||||
}
|
||||
|
||||
pub struct ComrakRenderer;
|
||||
pub struct ComrakPreviewRenderer;
|
||||
|
||||
impl MarkdownRenderer for ComrakRenderer {
|
||||
fn render(&self, content: &str) -> String {
|
||||
let adapter = SyntectAdapterBuilder::new().css().build();
|
||||
let adapter = SyntectAdapterBuilder::new()
|
||||
.syntax_set(build_syntax_set())
|
||||
.css()
|
||||
.build();
|
||||
let mut options = Options::default();
|
||||
options.extension.table = true;
|
||||
options.extension.strikethrough = true;
|
||||
@@ -24,7 +39,10 @@ impl MarkdownRenderer for ComrakRenderer {
|
||||
|
||||
impl MarkdownRenderer for ComrakPreviewRenderer {
|
||||
fn render(&self, content: &str) -> String {
|
||||
let adapter = SyntectAdapterBuilder::new().css().build();
|
||||
let adapter = SyntectAdapterBuilder::new()
|
||||
.syntax_set(build_syntax_set())
|
||||
.css()
|
||||
.build();
|
||||
let options = Options::default();
|
||||
let mut plugins = Plugins::default();
|
||||
plugins.render.codefence_syntax_highlighter = Some(&adapter);
|
||||
@@ -62,6 +80,13 @@ mod tests {
|
||||
assert!(html.contains("<span class="));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn typescript_syntax_is_highlighted() {
|
||||
let r = ComrakRenderer;
|
||||
let html = r.render("```typescript\ninterface Foo { id: string; }\n```\n");
|
||||
assert!(html.contains("<span class="), "TypeScript should produce highlighted spans");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn syntax_css_known_theme_contains_wrapper() {
|
||||
let css = syntax_css_for_theme("base16-ocean.dark");
|
||||
@@ -80,5 +105,4 @@ mod tests {
|
||||
let css = syntax_css_for_theme("nonexistent-theme-xyz");
|
||||
assert!(css.is_empty());
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "Pena",
|
||||
"version": "0.1.0",
|
||||
"version": "0.1.2",
|
||||
"identifier": "com.pena.app",
|
||||
"build": {
|
||||
"frontendDist": "../src"
|
||||
@@ -24,6 +24,12 @@
|
||||
"bundle": {
|
||||
"active": false,
|
||||
"targets": "all",
|
||||
"icon": []
|
||||
"icon": [
|
||||
"icons/32x32.png",
|
||||
"icons/128x128.png",
|
||||
"icons/128x128@2x.png",
|
||||
"icons/icon.icns",
|
||||
"icons/icon.ico"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
|
After Width: | Height: | Size: 9.4 KiB |
|
Before Width: | Height: | Size: 993 B After Width: | Height: | Size: 993 B |
@@ -22,6 +22,7 @@
|
||||
<div id="app-content">
|
||||
|
||||
<div id="view-home">
|
||||
<img class="home-logo" src="assets/icon.png" alt="Pena" />
|
||||
<h1 class="home-title">Pena</h1>
|
||||
<div class="home-buttons">
|
||||
<button id="btn-open-file">Ouvrir un fichier Markdown</button>
|
||||
@@ -47,6 +48,7 @@
|
||||
</button>
|
||||
<button id="btn-back" class="btn-back">← Accueil</button>
|
||||
</div>
|
||||
<div id="sidebar-resizer" class="sidebar__resizer" title="Redimensionner"></div>
|
||||
</div>
|
||||
<div id="content" class="content">
|
||||
<!-- contenu généré par JS -->
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { showHome, openPath } from './router.js';
|
||||
import { showHome, openPath, initWatcher } from './router.js';
|
||||
import { initCssModal } from './ui/css-modal.js';
|
||||
import { initSidebarResize } from './ui/sidebar-resize.js';
|
||||
|
||||
const win = window.__TAURI__.window.getCurrentWindow();
|
||||
|
||||
@@ -33,4 +34,6 @@ document.getElementById('btn-open-dir').addEventListener('click', async () => {
|
||||
document.getElementById('btn-back').addEventListener('click', showHome);
|
||||
|
||||
await initCssModal();
|
||||
initSidebarResize();
|
||||
await initWatcher();
|
||||
showHome();
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { appState } from './state/app-state.js';
|
||||
import { listMdFiles } from './services/files.js';
|
||||
import { startWatch, stopWatch, onFileChanged } from './services/watcher.js';
|
||||
import { renderSidebar } from './ui/sidebar.js';
|
||||
import { loadPage } from './ui/reader.js';
|
||||
import { saveRecent, renderRecents } from './ui/home.js';
|
||||
@@ -11,9 +12,15 @@ const content = document.querySelector('#content');
|
||||
const win = window.__TAURI__.window.getCurrentWindow();
|
||||
const titlebarTitle = document.getElementById('titlebar-title');
|
||||
|
||||
const navigate = filePath => loadPage(filePath, appState.currentMode, sidebarEl);
|
||||
|
||||
export function showHome() {
|
||||
stopWatch();
|
||||
appState.currentPath = null;
|
||||
appState.currentMode = null;
|
||||
appState.baseDir = null;
|
||||
appState.currentFile = null;
|
||||
appState.files = [];
|
||||
sidebarEl.innerHTML = '';
|
||||
win.setTitle('Pena — Markdown Viewer');
|
||||
titlebarTitle.textContent = 'Pena — Markdown Viewer';
|
||||
@@ -38,15 +45,21 @@ export async function openPath(path, mode) {
|
||||
const parentDir = path.split(sep).slice(0, -1).join(sep);
|
||||
try {
|
||||
const files = await listMdFiles(parentDir);
|
||||
appState.baseDir = parentDir;
|
||||
appState.files = files;
|
||||
sidebarEl.classList.remove('hidden');
|
||||
content.style.marginLeft = '';
|
||||
renderSidebar(parentDir, files, path, filePath => loadPage(filePath, appState.currentMode, sidebarEl));
|
||||
renderSidebar(parentDir, files, path, navigate);
|
||||
} catch {
|
||||
// Le dossier parent est illisible : on surveille au moins le fichier ouvert.
|
||||
appState.baseDir = path;
|
||||
appState.files = [];
|
||||
sidebarEl.innerHTML = '';
|
||||
sidebarEl.classList.add('hidden');
|
||||
content.style.marginLeft = '0';
|
||||
}
|
||||
await loadPage(path, appState.currentMode, sidebarEl);
|
||||
startWatch(appState.baseDir);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -56,6 +69,8 @@ export async function openPath(path, mode) {
|
||||
|
||||
try {
|
||||
const files = await listMdFiles(path);
|
||||
appState.baseDir = path;
|
||||
appState.files = files;
|
||||
showReader();
|
||||
|
||||
const sep = path.includes('\\') ? '\\' : '/';
|
||||
@@ -65,10 +80,48 @@ export async function openPath(path, mode) {
|
||||
});
|
||||
|
||||
const firstFile = home ?? files[0];
|
||||
renderSidebar(path, files, firstFile, filePath => loadPage(filePath, appState.currentMode, sidebarEl));
|
||||
renderSidebar(path, files, firstFile, navigate);
|
||||
await loadPage(firstFile, appState.currentMode, sidebarEl);
|
||||
startWatch(path);
|
||||
} catch (err) {
|
||||
showReader();
|
||||
content.innerHTML = `<p class="error">Impossible d'ouvrir le dossier : ${err}</p>`;
|
||||
}
|
||||
}
|
||||
|
||||
// Compare deux listes de fichiers indépendamment de l'ordre.
|
||||
function sameFiles(a, b) {
|
||||
if (a.length !== b.length) return false;
|
||||
const key = arr => arr.slice().sort().join('\n');
|
||||
return key(a) === key(b);
|
||||
}
|
||||
|
||||
// Réaction à un changement filesystem signalé par le backend (événement débouncé,
|
||||
// filtré sur les fichiers .md).
|
||||
async function handleFileChanged(changedPath) {
|
||||
if (!appState.baseDir || !appState.currentMode) return;
|
||||
|
||||
// Rafraîchir la sidebar uniquement si l'arborescence a changé (ajout/suppression),
|
||||
// pour ne pas replier les dossiers ouverts à chaque simple modification de contenu.
|
||||
try {
|
||||
const files = await listMdFiles(appState.baseDir);
|
||||
if (!sameFiles(files, appState.files)) {
|
||||
appState.files = files;
|
||||
renderSidebar(appState.baseDir, files, appState.currentFile, navigate);
|
||||
}
|
||||
} catch {
|
||||
// baseDir supprimé/illisible : on garde la sidebar en l'état.
|
||||
}
|
||||
|
||||
// Recharger le document affiché si c'est lui qui vient d'être modifié.
|
||||
if (changedPath === appState.currentFile) {
|
||||
await loadPage(appState.currentFile, appState.currentMode, sidebarEl);
|
||||
}
|
||||
}
|
||||
|
||||
// À appeler une fois au démarrage : enregistre le listener global de changements.
|
||||
export function initWatcher() {
|
||||
return onFileChanged(event => {
|
||||
handleFileChanged(event.payload.path);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1 +1,12 @@
|
||||
export const appState = { currentPath: null, currentMode: null };
|
||||
export const appState = {
|
||||
currentPath: null,
|
||||
currentMode: null,
|
||||
// Dossier réellement surveillé (racine de la sidebar) : le dossier ouvert en
|
||||
// mode « dir », ou le dossier parent du fichier ouvert en mode « file ».
|
||||
baseDir: null,
|
||||
// Fichier actuellement affiché dans le lecteur.
|
||||
currentFile: null,
|
||||
// Dernière liste de fichiers .md connue de `baseDir`, pour détecter les
|
||||
// ajouts/suppressions et ne rafraîchir la sidebar que si l'arborescence change.
|
||||
files: [],
|
||||
};
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
/* ── Reset ── */
|
||||
:root { --sidebar-width: 264px; }
|
||||
*, *::before, *::after { box-sizing: border-box; }
|
||||
::selection { background: #ffc3c3; }
|
||||
html, body { height: 100%; margin: 0; }
|
||||
@@ -91,6 +92,13 @@ body {
|
||||
background: #1a1b2e;
|
||||
}
|
||||
|
||||
.home-logo {
|
||||
width: 160px;
|
||||
height: 160px;
|
||||
object-fit: contain;
|
||||
margin: 0 0 16px;
|
||||
}
|
||||
|
||||
.home-title {
|
||||
color: #fff;
|
||||
font-size: 4rem;
|
||||
@@ -206,7 +214,7 @@ body {
|
||||
position: fixed;
|
||||
top: 36px;
|
||||
left: 0;
|
||||
width: 264px;
|
||||
width: var(--sidebar-width);
|
||||
height: calc(100vh - 36px);
|
||||
background: #1a1b2e;
|
||||
display: flex;
|
||||
@@ -215,6 +223,22 @@ body {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Poignée de redimensionnement */
|
||||
.sidebar__resizer {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
width: 6px;
|
||||
height: 100%;
|
||||
cursor: col-resize;
|
||||
z-index: 100;
|
||||
background: transparent;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
.sidebar__resizer:hover,
|
||||
.sidebar__resizer.is-dragging { background: rgba(129, 140, 248, 0.4); }
|
||||
body.is-resizing-sidebar { cursor: col-resize; user-select: none; }
|
||||
|
||||
.sidebar__scroll {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
@@ -315,10 +339,10 @@ body {
|
||||
|
||||
.nav-root-link {
|
||||
display: block;
|
||||
padding: 7px 24px;
|
||||
padding: 6px 24px;
|
||||
color: rgba(255,255,255,0.85);
|
||||
text-decoration: none;
|
||||
font-size: 16px;
|
||||
font-size: 14px;
|
||||
font-weight: 400;
|
||||
line-height: 1.4;
|
||||
transition: color 0.2s;
|
||||
@@ -358,33 +382,32 @@ body {
|
||||
.nav-arrow:hover { opacity: 1; background: rgba(255,255,255,0.08); }
|
||||
.nav-folder[open] > summary .nav-arrow { transform: rotate(90deg); opacity: 0.8; }
|
||||
|
||||
/* Les libellés de dossiers partagent la même police que les fichiers :
|
||||
même taille (14px), même graisse (400), sans majuscules. Seule la flèche ▶
|
||||
distingue visuellement un dossier d'un fichier. */
|
||||
.nav-folder-link, .nav-folder-name {
|
||||
flex: 1;
|
||||
display: block;
|
||||
padding: 6px 24px 6px 0;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
color: rgba(255,255,255,0.35);
|
||||
font-size: 14px;
|
||||
font-weight: 400;
|
||||
line-height: 1.4;
|
||||
color: rgba(255,255,255,0.85);
|
||||
text-decoration: none;
|
||||
transition: color 0.2s;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.nav-folder-link:hover { color: rgba(255,255,255,0.85); }
|
||||
.nav-folder-link:hover { color: #ff5577; }
|
||||
.nav-folder-link.active { color: #ff5577; }
|
||||
|
||||
/* Dossiers imbriqués : même style que les fichiers imbriqués (couleur atténuée). */
|
||||
.nav-folder .nav-folder > summary .nav-folder-link,
|
||||
.nav-folder .nav-folder > summary .nav-folder-name {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
text-transform: none;
|
||||
letter-spacing: 0;
|
||||
color: rgba(255,255,255,0.45);
|
||||
color: rgba(255,255,255,0.55);
|
||||
}
|
||||
.nav-folder .nav-folder > summary .nav-folder-link:hover { color: rgba(255,255,255,0.9); }
|
||||
.nav-folder .nav-folder > summary .nav-folder-link:hover { color: #ff5577; }
|
||||
|
||||
.nav-file-link {
|
||||
display: block;
|
||||
@@ -402,16 +425,16 @@ body {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.nav-depth-1 > .nav-file-link { padding: 5px 24px 5px 52px; }
|
||||
.nav-depth-1 > .nav-file-link { padding: 6px 24px 6px 52px; }
|
||||
.nav-depth-1 > .nav-folder > summary { padding-left: 24px; }
|
||||
.nav-depth-2 > .nav-file-link { padding: 4px 24px 4px 68px; }
|
||||
.nav-depth-2 > .nav-file-link { padding: 6px 24px 6px 68px; }
|
||||
.nav-depth-2 > .nav-folder > summary { padding-left: 40px; }
|
||||
.nav-depth-3 > .nav-file-link { padding: 4px 24px 4px 84px; }
|
||||
.nav-depth-3 > .nav-file-link { padding: 6px 24px 6px 84px; }
|
||||
.nav-depth-3 > .nav-folder > summary { padding-left: 56px; }
|
||||
|
||||
/* ── Content ── */
|
||||
.content {
|
||||
margin-left: 264px;
|
||||
margin-left: var(--sidebar-width);
|
||||
padding-top: 48px;
|
||||
padding-right: 72px;
|
||||
padding-bottom: 96px;
|
||||
@@ -455,6 +478,29 @@ body {
|
||||
.content pre:not(.syntax-highlighting) { background: #f4f4f7; }
|
||||
.content pre code { background: none; padding: 0; font-weight: 400; font-size: 13px; border: none; border-radius: 0; color: inherit; }
|
||||
|
||||
/* ── Bouton « copier » des blocs de code ── */
|
||||
.content .code-block { position: relative; }
|
||||
.content .code-block .copy-code-btn {
|
||||
position: absolute;
|
||||
top: 24px;
|
||||
right: 12px;
|
||||
padding: 4px 10px;
|
||||
font-family: inherit;
|
||||
font-size: 12px;
|
||||
line-height: 1.2;
|
||||
color: #c0c5ce;
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
border: 1px solid rgba(255, 255, 255, 0.18);
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
opacity: 0;
|
||||
transition: opacity 0.15s ease, background 0.15s ease;
|
||||
}
|
||||
.content .code-block:hover .copy-code-btn,
|
||||
.content .code-block .copy-code-btn:focus-visible { opacity: 1; }
|
||||
.content .code-block .copy-code-btn:hover { background: rgba(255, 255, 255, 0.18); }
|
||||
.content .code-block .copy-code-btn.copied { color: #a3be8c; border-color: rgba(163, 190, 140, 0.5); }
|
||||
|
||||
/* ── Coloration syntaxique (base16-ocean.dark) ── */
|
||||
.syntax-highlighting {
|
||||
color: #c0c5ce;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { convertFile } from '../services/markdown.js';
|
||||
import { appState } from '../state/app-state.js';
|
||||
|
||||
const win = window.__TAURI__.window.getCurrentWindow();
|
||||
const titlebarTitle = document.getElementById('titlebar-title');
|
||||
@@ -35,10 +36,43 @@ function wireLinks(filePath, mode, sidebarEl) {
|
||||
});
|
||||
}
|
||||
|
||||
function addCopyButtons() {
|
||||
content.querySelectorAll('pre').forEach(pre => {
|
||||
if (pre.parentElement?.classList.contains('code-block')) return;
|
||||
|
||||
const wrapper = document.createElement('div');
|
||||
wrapper.className = 'code-block';
|
||||
pre.parentNode.insertBefore(wrapper, pre);
|
||||
wrapper.appendChild(pre);
|
||||
|
||||
const btn = document.createElement('button');
|
||||
btn.type = 'button';
|
||||
btn.className = 'copy-code-btn';
|
||||
btn.textContent = 'Copier';
|
||||
btn.addEventListener('click', async () => {
|
||||
const code = (pre.querySelector('code') ?? pre).textContent;
|
||||
try {
|
||||
await navigator.clipboard.writeText(code);
|
||||
btn.textContent = 'Copié !';
|
||||
btn.classList.add('copied');
|
||||
} catch {
|
||||
btn.textContent = 'Erreur';
|
||||
}
|
||||
setTimeout(() => {
|
||||
btn.textContent = 'Copier';
|
||||
btn.classList.remove('copied');
|
||||
}, 1500);
|
||||
});
|
||||
wrapper.appendChild(btn);
|
||||
});
|
||||
}
|
||||
|
||||
export async function loadPage(filePath, mode, sidebarEl) {
|
||||
try {
|
||||
const html = await convertFile(filePath);
|
||||
appState.currentFile = filePath;
|
||||
content.innerHTML = html;
|
||||
addCopyButtons();
|
||||
|
||||
const basename = filePath.split('/').pop().split('\\').pop();
|
||||
const title = basename.replace(/\.md$/i, '');
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
const STORAGE_KEY = 'pena.sidebarWidth';
|
||||
const MIN_WIDTH = 180;
|
||||
const MAX_WIDTH = 520;
|
||||
|
||||
function clamp(width) {
|
||||
return Math.min(MAX_WIDTH, Math.max(MIN_WIDTH, width));
|
||||
}
|
||||
|
||||
function applyWidth(width) {
|
||||
document.documentElement.style.setProperty('--sidebar-width', `${width}px`);
|
||||
}
|
||||
|
||||
export function initSidebarResize() {
|
||||
const handle = document.getElementById('sidebar-resizer');
|
||||
if (!handle) return;
|
||||
|
||||
const stored = parseInt(localStorage.getItem(STORAGE_KEY), 10);
|
||||
if (!Number.isNaN(stored)) applyWidth(clamp(stored));
|
||||
|
||||
let dragging = false;
|
||||
|
||||
const onMove = (e) => {
|
||||
if (!dragging) return;
|
||||
applyWidth(clamp(e.clientX));
|
||||
};
|
||||
|
||||
const onUp = () => {
|
||||
if (!dragging) return;
|
||||
dragging = false;
|
||||
handle.classList.remove('is-dragging');
|
||||
document.body.classList.remove('is-resizing-sidebar');
|
||||
const width = parseInt(
|
||||
getComputedStyle(document.documentElement).getPropertyValue('--sidebar-width'),
|
||||
10,
|
||||
);
|
||||
if (!Number.isNaN(width)) localStorage.setItem(STORAGE_KEY, String(width));
|
||||
window.removeEventListener('mousemove', onMove);
|
||||
window.removeEventListener('mouseup', onUp);
|
||||
};
|
||||
|
||||
handle.addEventListener('mousedown', (e) => {
|
||||
e.preventDefault();
|
||||
dragging = true;
|
||||
handle.classList.add('is-dragging');
|
||||
document.body.classList.add('is-resizing-sidebar');
|
||||
window.addEventListener('mousemove', onMove);
|
||||
window.addEventListener('mouseup', onUp);
|
||||
});
|
||||
}
|
||||
@@ -1,5 +1,13 @@
|
||||
import { escapeHtml } from '../utils.js';
|
||||
|
||||
// Tri « naturel » : les préfixes numériques sont comparés comme des nombres
|
||||
// (10 vient après 9, pas après 1).
|
||||
const collator = new Intl.Collator(undefined, { numeric: true, sensitivity: 'base' });
|
||||
|
||||
function cleanLabel(name) {
|
||||
return name.replace(/\.md$/i, '').replace(/-/g, ' ');
|
||||
}
|
||||
|
||||
function buildTree(relPaths, sep) {
|
||||
const tree = { _files: [], _dirs: {} };
|
||||
relPaths.forEach((rel) => {
|
||||
@@ -19,8 +27,11 @@ function renderTree(node, prefix, sep, currentRel, depth) {
|
||||
let html = '';
|
||||
const depthClass = depth > 0 ? ` class="nav-depth-${Math.min(depth, 3)}"` : '';
|
||||
|
||||
node._files.slice().sort().forEach((rel) => {
|
||||
const label = rel.split(sep).pop().replace(/\.md$/i, '').replace(/-/g, ' ');
|
||||
node._files
|
||||
.slice()
|
||||
.sort((a, b) => collator.compare(a.split(sep).pop(), b.split(sep).pop()))
|
||||
.forEach((rel) => {
|
||||
const label = cleanLabel(rel.split(sep).pop());
|
||||
const abs = prefix + rel;
|
||||
const cls = rel === currentRel ? ' active' : '';
|
||||
if (depth === 0) {
|
||||
@@ -30,7 +41,7 @@ function renderTree(node, prefix, sep, currentRel, depth) {
|
||||
}
|
||||
});
|
||||
|
||||
Object.keys(node._dirs).sort().forEach((dirName) => {
|
||||
Object.keys(node._dirs).sort((a, b) => collator.compare(a, b)).forEach((dirName) => {
|
||||
const child = node._dirs[dirName];
|
||||
const isOpen = currentRel && currentRel.split(sep).includes(dirName);
|
||||
const openAttr = isOpen ? ' open' : '';
|
||||
@@ -40,9 +51,9 @@ function renderTree(node, prefix, sep, currentRel, depth) {
|
||||
if (homeFile) {
|
||||
const abs = prefix + homeFile;
|
||||
const cls = homeFile === currentRel ? ' active' : '';
|
||||
folderLabel = `<a href="#" class="nav-folder-link${cls}" data-path="${escapeHtml(abs)}" onclick="event.stopPropagation()">${escapeHtml(dirName)}</a>`;
|
||||
folderLabel = `<a href="#" class="nav-folder-link${cls}" data-path="${escapeHtml(abs)}" onclick="event.stopPropagation()">${escapeHtml(cleanLabel(dirName))}</a>`;
|
||||
} else {
|
||||
folderLabel = `<span class="nav-folder-name">${escapeHtml(dirName)}</span>`;
|
||||
folderLabel = `<span class="nav-folder-name">${escapeHtml(cleanLabel(dirName))}</span>`;
|
||||
}
|
||||
|
||||
const arrow = `<span class="nav-arrow" onclick="event.preventDefault();event.stopPropagation();var d=this.closest('details');d.open=!d.open">▶</span>`;
|
||||
|
||||