feat: add syntax highlighting and code block styling with theme support

This commit is contained in:
2025-10-21 08:34:46 -04:00
parent 062d743481
commit d788c9d267
75 changed files with 4783 additions and 830 deletions
+460
View File
@@ -0,0 +1,460 @@
import { Extension } from '@codemirror/state';
import { EditorView } from '@codemirror/view';
import { HighlightStyle, syntaxHighlighting } from '@codemirror/language';
import { tags } from '@lezer/highlight';
import type { ThemeId } from './services/theme.service';
/**
* Génère les extensions CodeMirror 6 pour un thème donné
* @param themeId - L'identifiant du thème
* @param mode - 'light' ou 'dark'
* @returns Extensions CodeMirror 6
*/
export function cm6ThemeFor(themeId: ThemeId, mode: 'light' | 'dark'): Extension[] {
switch (themeId) {
case 'obsidian':
return createObsidianTheme(mode);
case 'nord':
return createNordTheme(mode);
case 'notion':
return createNotionTheme(mode);
case 'github':
return createGitHubTheme(mode);
case 'discord':
return createDiscordTheme(mode);
case 'monokai':
return createMonokaiTheme(mode);
case 'dark':
return createDefaultDarkTheme();
case 'light':
default:
return createDefaultLightTheme();
}
}
// ============================================================================
// THÈME PAR DÉFAUT LIGHT
// ============================================================================
function createDefaultLightTheme(): Extension[] {
const theme = EditorView.theme({
'&': {
backgroundColor: '#ffffff',
color: '#111827',
},
'.cm-content': {
caretColor: '#3b82f6',
},
'.cm-cursor, .cm-dropCursor': {
borderLeftColor: '#3b82f6',
},
'&.cm-focused .cm-selectionBackground, ::selection': {
backgroundColor: '#bfdbfe',
},
'.cm-activeLine': {
backgroundColor: '#f1f5f9',
},
'.cm-gutters': {
backgroundColor: '#f8fafc',
color: '#94a3b8',
border: 'none',
},
'.cm-activeLineGutter': {
backgroundColor: '#e2e8f0',
},
}, { dark: false });
const highlightStyle = HighlightStyle.define([
{ tag: tags.keyword, color: '#2563eb', fontWeight: 'bold' },
{ tag: tags.comment, color: '#6b7280', fontStyle: 'italic' },
{ tag: tags.string, color: '#22c55e' },
{ tag: tags.number, color: '#f59e0b' },
{ tag: tags.bool, color: '#2563eb' },
{ tag: tags.variableName, color: '#111827' },
{ tag: tags.function(tags.variableName), color: '#3b82f6' },
{ tag: tags.typeName, color: '#8b5cf6' },
{ tag: tags.tagName, color: '#2563eb' },
{ tag: tags.attributeName, color: '#14b8a6' },
{ tag: tags.heading, color: '#111827', fontWeight: 'bold' },
{ tag: tags.link, color: '#2563eb', textDecoration: 'underline' },
{ tag: tags.emphasis, fontStyle: 'italic' },
{ tag: tags.strong, fontWeight: 'bold' },
]);
return [theme, syntaxHighlighting(highlightStyle)];
}
// ============================================================================
// THÈME PAR DÉFAUT DARK
// ============================================================================
function createDefaultDarkTheme(): Extension[] {
const theme = EditorView.theme({
'&': {
backgroundColor: '#0f172a',
color: '#e5e7eb',
},
'.cm-content': {
caretColor: '#60a5fa',
},
'.cm-cursor, .cm-dropCursor': {
borderLeftColor: '#60a5fa',
},
'&.cm-focused .cm-selectionBackground, ::selection': {
backgroundColor: '#1e3a8a',
},
'.cm-activeLine': {
backgroundColor: '#1e293b',
},
'.cm-gutters': {
backgroundColor: '#0b1220',
color: '#64748b',
border: 'none',
},
'.cm-activeLineGutter': {
backgroundColor: '#1e293b',
},
}, { dark: true });
const highlightStyle = HighlightStyle.define([
{ tag: tags.keyword, color: '#60a5fa', fontWeight: 'bold' },
{ tag: tags.comment, color: '#94a3b8', fontStyle: 'italic' },
{ tag: tags.string, color: '#34d399' },
{ tag: tags.number, color: '#fbbf24' },
{ tag: tags.bool, color: '#60a5fa' },
{ tag: tags.variableName, color: '#e5e7eb' },
{ tag: tags.function(tags.variableName), color: '#60a5fa' },
{ tag: tags.typeName, color: '#a78bfa' },
{ tag: tags.tagName, color: '#60a5fa' },
{ tag: tags.attributeName, color: '#2dd4bf' },
{ tag: tags.heading, color: '#f3f4f6', fontWeight: 'bold' },
{ tag: tags.link, color: '#93c5fd', textDecoration: 'underline' },
{ tag: tags.emphasis, fontStyle: 'italic' },
{ tag: tags.strong, fontWeight: 'bold' },
]);
return [theme, syntaxHighlighting(highlightStyle)];
}
// ============================================================================
// OBSIDIAN
// ============================================================================
function createObsidianTheme(mode: 'light' | 'dark'): Extension[] {
if (mode === 'light') {
const theme = EditorView.theme({
'&': { backgroundColor: '#ffffff', color: '#2e3338' },
'.cm-content': { caretColor: '#a89984' },
'.cm-cursor, .cm-dropCursor': { borderLeftColor: '#a89984' },
'&.cm-focused .cm-selectionBackground, ::selection': { backgroundColor: '#d4c5aa' },
'.cm-activeLine': { backgroundColor: '#f5f3ef' },
'.cm-gutters': { backgroundColor: '#fafaf8', color: '#888', border: 'none' },
'.cm-activeLineGutter': { backgroundColor: '#f0ede6' },
}, { dark: false });
const highlightStyle = HighlightStyle.define([
{ tag: tags.keyword, color: '#7c6f64', fontWeight: 'bold' },
{ tag: tags.comment, color: '#a89984', fontStyle: 'italic' },
{ tag: tags.string, color: '#427b58' },
{ tag: tags.number, color: '#b16286' },
{ tag: tags.bool, color: '#7c6f64' },
{ tag: tags.heading, color: '#2e3338', fontWeight: 'bold' },
{ tag: tags.link, color: '#076678', textDecoration: 'underline' },
]);
return [theme, syntaxHighlighting(highlightStyle)];
} else {
const theme = EditorView.theme({
'&': { backgroundColor: '#1e1e1e', color: '#e0e0e0' },
'.cm-content': { caretColor: '#a89984' },
'.cm-cursor, .cm-dropCursor': { borderLeftColor: '#a89984' },
'&.cm-focused .cm-selectionBackground, ::selection': { backgroundColor: '#3e3e3e' },
'.cm-activeLine': { backgroundColor: '#252525' },
'.cm-gutters': { backgroundColor: '#1a1a1a', color: '#666', border: 'none' },
'.cm-activeLineGutter': { backgroundColor: '#2a2a2a' },
}, { dark: true });
const highlightStyle = HighlightStyle.define([
{ tag: tags.keyword, color: '#a89984', fontWeight: 'bold' },
{ tag: tags.comment, color: '#7c6f64', fontStyle: 'italic' },
{ tag: tags.string, color: '#7aa2f7' },
{ tag: tags.number, color: '#d3869b' },
{ tag: tags.heading, color: '#e0e0e0', fontWeight: 'bold' },
{ tag: tags.link, color: '#7aa2f7', textDecoration: 'underline' },
]);
return [theme, syntaxHighlighting(highlightStyle)];
}
}
// ============================================================================
// NORD
// ============================================================================
function createNordTheme(mode: 'light' | 'dark'): Extension[] {
if (mode === 'light') {
const theme = EditorView.theme({
'&': { backgroundColor: '#ffffff', color: '#2e3440' },
'.cm-content': { caretColor: '#5e81ac' },
'.cm-cursor, .cm-dropCursor': { borderLeftColor: '#5e81ac' },
'&.cm-focused .cm-selectionBackground, ::selection': { backgroundColor: 'rgba(136, 192, 208, 0.3)' },
'.cm-activeLine': { backgroundColor: '#eceff4' },
'.cm-gutters': { backgroundColor: '#eceff4', color: '#5e81ac', border: 'none' },
'.cm-activeLineGutter': { backgroundColor: '#e5e9f0' },
}, { dark: false });
const highlightStyle = HighlightStyle.define([
{ tag: tags.keyword, color: '#5e81ac', fontWeight: 'bold' },
{ tag: tags.comment, color: '#4c566a', fontStyle: 'italic' },
{ tag: tags.string, color: '#a3be8c' },
{ tag: tags.number, color: '#b48ead' },
{ tag: tags.bool, color: '#5e81ac' },
{ tag: tags.variableName, color: '#2e3440' },
{ tag: tags.function(tags.variableName), color: '#5e81ac' },
{ tag: tags.typeName, color: '#8fbcbb' },
{ tag: tags.heading, color: '#2e3440', fontWeight: 'bold' },
{ tag: tags.link, color: '#5e81ac', textDecoration: 'underline' },
]);
return [theme, syntaxHighlighting(highlightStyle)];
} else {
const theme = EditorView.theme({
'&': { backgroundColor: '#2e3440', color: '#e5e9f0' },
'.cm-content': { caretColor: '#88c0d0' },
'.cm-cursor, .cm-dropCursor': { borderLeftColor: '#88c0d0' },
'&.cm-focused .cm-selectionBackground, ::selection': { backgroundColor: 'rgba(67, 76, 94, 0.8)' },
'.cm-activeLine': { backgroundColor: '#3b4252' },
'.cm-gutters': { backgroundColor: '#2e3440', color: '#4c566a', border: 'none' },
'.cm-activeLineGutter': { backgroundColor: '#434c5e' },
}, { dark: true });
const highlightStyle = HighlightStyle.define([
{ tag: tags.keyword, color: '#81a1c1', fontWeight: 'bold' },
{ tag: tags.comment, color: '#616e88', fontStyle: 'italic' },
{ tag: tags.string, color: '#a3be8c' },
{ tag: tags.number, color: '#b48ead' },
{ tag: tags.bool, color: '#81a1c1' },
{ tag: tags.variableName, color: '#d8dee9' },
{ tag: tags.function(tags.variableName), color: '#88c0d0' },
{ tag: tags.typeName, color: '#8fbcbb' },
{ tag: tags.heading, color: '#e5e9f0', fontWeight: 'bold' },
{ tag: tags.link, color: '#88c0d0', textDecoration: 'underline' },
]);
return [theme, syntaxHighlighting(highlightStyle)];
}
}
// ============================================================================
// NOTION
// ============================================================================
function createNotionTheme(mode: 'light' | 'dark'): Extension[] {
if (mode === 'light') {
const theme = EditorView.theme({
'&': { backgroundColor: '#ffffff', color: '#2f3437' },
'.cm-content': { caretColor: '#2f3437' },
'.cm-cursor, .cm-dropCursor': { borderLeftColor: '#2f3437' },
'&.cm-focused .cm-selectionBackground, ::selection': { backgroundColor: 'rgba(37, 99, 235, 0.2)' },
'.cm-activeLine': { backgroundColor: '#f7f7f5' },
'.cm-gutters': { backgroundColor: '#f7f7f5', color: '#9b9a97', border: 'none' },
'.cm-activeLineGutter': { backgroundColor: '#eeeeec' },
}, { dark: false });
const highlightStyle = HighlightStyle.define([
{ tag: tags.keyword, color: '#2f3437', fontWeight: 'bold' },
{ tag: tags.comment, color: '#6b7280', fontStyle: 'italic' },
{ tag: tags.string, color: '#14b8a6' },
{ tag: tags.number, color: '#f59e0b' },
{ tag: tags.bool, color: '#2563eb' },
{ tag: tags.heading, color: '#2f3437', fontWeight: 'bold' },
{ tag: tags.link, color: '#2563eb', textDecoration: 'underline' },
]);
return [theme, syntaxHighlighting(highlightStyle)];
} else {
const theme = EditorView.theme({
'&': { backgroundColor: '#1b1b1b', color: '#e7e7e5' },
'.cm-content': { caretColor: '#cfcfc9' },
'.cm-cursor, .cm-dropCursor': { borderLeftColor: '#cfcfc9' },
'&.cm-focused .cm-selectionBackground, ::selection': { backgroundColor: 'rgba(147, 197, 253, 0.25)' },
'.cm-activeLine': { backgroundColor: '#171717' },
'.cm-gutters': { backgroundColor: '#171717', color: '#9b9a97', border: 'none' },
'.cm-activeLineGutter': { backgroundColor: '#111111' },
}, { dark: true });
const highlightStyle = HighlightStyle.define([
{ tag: tags.keyword, color: '#cfcfc9', fontWeight: 'bold' },
{ tag: tags.comment, color: '#9b9a97', fontStyle: 'italic' },
{ tag: tags.string, color: '#2dd4bf' },
{ tag: tags.number, color: '#fbbf24' },
{ tag: tags.bool, color: '#93c5fd' },
{ tag: tags.heading, color: '#e7e7e5', fontWeight: 'bold' },
{ tag: tags.link, color: '#93c5fd', textDecoration: 'underline' },
]);
return [theme, syntaxHighlighting(highlightStyle)];
}
}
// ============================================================================
// GITHUB
// ============================================================================
function createGitHubTheme(mode: 'light' | 'dark'): Extension[] {
if (mode === 'light') {
const theme = EditorView.theme({
'&': { backgroundColor: '#ffffff', color: '#24292f' },
'.cm-content': { caretColor: '#0969da' },
'.cm-cursor, .cm-dropCursor': { borderLeftColor: '#0969da' },
'&.cm-focused .cm-selectionBackground, ::selection': { backgroundColor: 'rgba(9, 105, 218, 0.15)' },
'.cm-activeLine': { backgroundColor: '#f6f8fa' },
'.cm-gutters': { backgroundColor: '#f6f8fa', color: '#57606a', border: 'none' },
'.cm-activeLineGutter': { backgroundColor: '#eaeef2' },
}, { dark: false });
const highlightStyle = HighlightStyle.define([
{ tag: tags.keyword, color: '#cf222e', fontWeight: 'bold' },
{ tag: tags.comment, color: '#57606a', fontStyle: 'italic' },
{ tag: tags.string, color: '#0a3069' },
{ tag: tags.number, color: '#0550ae' },
{ tag: tags.bool, color: '#0550ae' },
{ tag: tags.variableName, color: '#24292f' },
{ tag: tags.function(tags.variableName), color: '#8250df' },
{ tag: tags.heading, color: '#24292f', fontWeight: 'bold' },
{ tag: tags.link, color: '#0969da', textDecoration: 'underline' },
]);
return [theme, syntaxHighlighting(highlightStyle)];
} else {
const theme = EditorView.theme({
'&': { backgroundColor: '#0d1117', color: '#c9d1d9' },
'.cm-content': { caretColor: '#58a6ff' },
'.cm-cursor, .cm-dropCursor': { borderLeftColor: '#58a6ff' },
'&.cm-focused .cm-selectionBackground, ::selection': { backgroundColor: 'rgba(88, 166, 255, 0.25)' },
'.cm-activeLine': { backgroundColor: '#161b22' },
'.cm-gutters': { backgroundColor: '#0d1117', color: '#8b949e', border: 'none' },
'.cm-activeLineGutter': { backgroundColor: '#21262d' },
}, { dark: true });
const highlightStyle = HighlightStyle.define([
{ tag: tags.keyword, color: '#ff7b72', fontWeight: 'bold' },
{ tag: tags.comment, color: '#8b949e', fontStyle: 'italic' },
{ tag: tags.string, color: '#a5d6ff' },
{ tag: tags.number, color: '#79c0ff' },
{ tag: tags.bool, color: '#79c0ff' },
{ tag: tags.variableName, color: '#c9d1d9' },
{ tag: tags.function(tags.variableName), color: '#d2a8ff' },
{ tag: tags.heading, color: '#c9d1d9', fontWeight: 'bold' },
{ tag: tags.link, color: '#58a6ff', textDecoration: 'underline' },
]);
return [theme, syntaxHighlighting(highlightStyle)];
}
}
// ============================================================================
// DISCORD
// ============================================================================
function createDiscordTheme(mode: 'light' | 'dark'): Extension[] {
if (mode === 'light') {
const theme = EditorView.theme({
'&': { backgroundColor: '#ffffff', color: '#232428' },
'.cm-content': { caretColor: '#5865f2' },
'.cm-cursor, .cm-dropCursor': { borderLeftColor: '#5865f2' },
'&.cm-focused .cm-selectionBackground, ::selection': { backgroundColor: 'rgba(88, 101, 242, 0.2)' },
'.cm-activeLine': { backgroundColor: '#f6f7f9' },
'.cm-gutters': { backgroundColor: '#f6f7f9', color: '#80848e', border: 'none' },
'.cm-activeLineGutter': { backgroundColor: '#eef0f5' },
}, { dark: false });
const highlightStyle = HighlightStyle.define([
{ tag: tags.keyword, color: '#5865f2', fontWeight: 'bold' },
{ tag: tags.comment, color: '#80848e', fontStyle: 'italic' },
{ tag: tags.string, color: '#23a55a' },
{ tag: tags.number, color: '#eb459e' },
{ tag: tags.bool, color: '#5865f2' },
{ tag: tags.variableName, color: '#232428' },
{ tag: tags.function(tags.variableName), color: '#3e63ff' },
{ tag: tags.heading, color: '#232428', fontWeight: 'bold' },
{ tag: tags.link, color: '#3e63ff', textDecoration: 'underline' },
]);
return [theme, syntaxHighlighting(highlightStyle)];
} else {
const theme = EditorView.theme({
'&': { backgroundColor: '#313338', color: '#f2f3f5' },
'.cm-content': { caretColor: '#5865f2' },
'.cm-cursor, .cm-dropCursor': { borderLeftColor: '#5865f2' },
'&.cm-focused .cm-selectionBackground, ::selection': { backgroundColor: 'rgba(64, 66, 73, 0.8)' },
'.cm-activeLine': { backgroundColor: '#383a40' },
'.cm-gutters': { backgroundColor: '#232428', color: '#80848e', border: 'none' },
'.cm-activeLineGutter': { backgroundColor: '#2b2d31' },
}, { dark: true });
const highlightStyle = HighlightStyle.define([
{ tag: tags.keyword, color: '#5865f2', fontWeight: 'bold' },
{ tag: tags.comment, color: '#80848e', fontStyle: 'italic' },
{ tag: tags.string, color: '#23a55a' },
{ tag: tags.number, color: '#eb459e' },
{ tag: tags.bool, color: '#5865f2' },
{ tag: tags.variableName, color: '#dbdee1' },
{ tag: tags.function(tags.variableName), color: '#71a1ff' },
{ tag: tags.heading, color: '#f2f3f5', fontWeight: 'bold' },
{ tag: tags.link, color: '#00a8fc', textDecoration: 'underline' },
]);
return [theme, syntaxHighlighting(highlightStyle)];
}
}
// ============================================================================
// MONOKAI
// ============================================================================
function createMonokaiTheme(mode: 'light' | 'dark'): Extension[] {
if (mode === 'light') {
const theme = EditorView.theme({
'&': { backgroundColor: '#ffffff', color: '#2b2b2b' },
'.cm-content': { caretColor: '#a6e22e' },
'.cm-cursor, .cm-dropCursor': { borderLeftColor: '#a6e22e' },
'&.cm-focused .cm-selectionBackground, ::selection': { backgroundColor: 'rgba(166, 226, 46, 0.2)' },
'.cm-activeLine': { backgroundColor: '#fbfbf7' },
'.cm-gutters': { backgroundColor: '#fbfbf7', color: '#8a8a8a', border: 'none' },
'.cm-activeLineGutter': { backgroundColor: '#f2f2ea' },
}, { dark: false });
const highlightStyle = HighlightStyle.define([
{ tag: tags.keyword, color: '#c7254e', fontWeight: 'bold' },
{ tag: tags.comment, color: '#8a8a8a', fontStyle: 'italic' },
{ tag: tags.string, color: '#d4c74e' },
{ tag: tags.number, color: '#ae81ff' },
{ tag: tags.bool, color: '#ae81ff' },
{ tag: tags.variableName, color: '#2b2b2b' },
{ tag: tags.function(tags.variableName), color: '#75aa1f' },
{ tag: tags.typeName, color: '#2b7b8e' },
{ tag: tags.heading, color: '#2b2b2b', fontWeight: 'bold' },
{ tag: tags.link, color: '#2b7b8e', textDecoration: 'underline' },
]);
return [theme, syntaxHighlighting(highlightStyle)];
} else {
const theme = EditorView.theme({
'&': { backgroundColor: '#272822', color: '#f8f8f2' },
'.cm-content': { caretColor: '#f8f8f0' },
'.cm-cursor, .cm-dropCursor': { borderLeftColor: '#f8f8f0' },
'&.cm-focused .cm-selectionBackground, ::selection': { backgroundColor: 'rgba(73, 72, 62, 0.8)' },
'.cm-activeLine': { backgroundColor: '#3e3d32' },
'.cm-gutters': { backgroundColor: '#23241f', color: '#90908a', border: 'none' },
'.cm-activeLineGutter': { backgroundColor: '#2d2e26' },
}, { dark: true });
const highlightStyle = HighlightStyle.define([
{ tag: tags.keyword, color: '#f92672', fontWeight: 'bold' },
{ tag: tags.comment, color: '#75715e', fontStyle: 'italic' },
{ tag: tags.string, color: '#e6db74' },
{ tag: tags.number, color: '#ae81ff' },
{ tag: tags.bool, color: '#ae81ff' },
{ tag: tags.variableName, color: '#f8f8f2' },
{ tag: tags.function(tags.variableName), color: '#a6e22e' },
{ tag: tags.typeName, color: '#66d9ef' },
{ tag: tags.heading, color: '#f8f8f2', fontWeight: 'bold' },
{ tag: tags.link, color: '#66d9ef', textDecoration: 'underline' },
]);
return [theme, syntaxHighlighting(highlightStyle)];
}
}
+102 -49
View File
@@ -1,23 +1,46 @@
import { DOCUMENT } from '@angular/common';
import { DestroyRef, Inject, Injectable, effect, signal, computed, inject } from '@angular/core';
import { BehaviorSubject } from 'rxjs';
import { LogService } from '../../../core/logging/log.service';
import { cm6ThemeFor } from '../codemirror-themes';
export type ThemeName = 'light' | 'dark';
export type ThemeMode = 'system' | 'light' | 'dark';
export type ThemeId = 'light' | 'dark' | 'obsidian' | 'nord' | 'notion' | 'github' | 'discord' | 'monokai';
export type Language = 'fr' | 'en';
export interface ThemePrefs {
mode: ThemeMode;
theme: ThemeId;
language: Language;
}
const DEFAULT_PREFS: ThemePrefs = {
mode: 'system',
theme: 'light',
language: 'fr'
};
@Injectable({ providedIn: 'root' })
export class ThemeService {
private readonly logService = inject(LogService);
private static readonly STORAGE_KEY = 'obsiwatcher.theme';
private static readonly STORAGE_KEY = 'obsiviewer.preferences.v1';
private readonly document = this.doc;
private readonly prefersDarkQuery = typeof window !== 'undefined'
? window.matchMedia('(prefers-color-scheme: dark)')
: null;
private readonly currentTheme = signal<ThemeName>(this.detectSystemTheme());
private prefs: ThemePrefs = { ...DEFAULT_PREFS };
private prefs$ = new BehaviorSubject<ThemePrefs>(this.prefs);
public readonly onPrefs$ = this.prefs$.asObservable();
private readonly currentTheme = signal<ThemeId>(this.prefs.theme);
private readonly currentMode = signal<ThemeMode>(this.prefs.mode);
readonly theme = computed(() => this.currentTheme());
readonly isDark = computed(() => this.currentTheme() === 'dark');
readonly mode = computed(() => this.currentMode());
readonly isDark = computed(() => this.resolveDark());
readonly language = computed(() => this.prefs.language);
constructor(
@Inject(DOCUMENT) private readonly doc: Document,
@@ -25,14 +48,15 @@ export class ThemeService {
) {
effect(() => {
const theme = this.currentTheme();
this.applyTheme(theme);
this.persist(theme);
const mode = this.currentMode();
this.applyToDom();
this.persist();
});
if (this.prefersDarkQuery) {
const listener = (event: MediaQueryListEvent) => {
if (!this.getStoredTheme()) {
this.currentTheme.set(event.matches ? 'dark' : 'light');
if (this.prefs.mode === 'system') {
this.applyToDom();
}
};
this.prefersDarkQuery.addEventListener('change', listener);
@@ -43,19 +67,48 @@ export class ThemeService {
}
initFromStorage(): void {
const stored = this.getStoredTheme();
if (stored) {
this.currentTheme.set(stored);
} else {
this.currentTheme.set(this.detectSystemTheme());
try {
if (typeof window === 'undefined' || !window.localStorage) {
this.applyToDom();
return;
}
const raw = window.localStorage.getItem(ThemeService.STORAGE_KEY);
if (raw) {
this.prefs = { ...DEFAULT_PREFS, ...JSON.parse(raw) };
this.currentTheme.set(this.prefs.theme);
this.currentMode.set(this.prefs.mode);
}
} catch {
// Ignore storage errors
}
this.applyToDom();
this.prefs$.next(this.prefs);
}
setMode(mode: ThemeMode): void {
const previousMode = this.prefs.mode;
this.prefs.mode = mode;
this.currentMode.set(mode);
this.persist();
this.applyToDom();
this.prefs$.next(this.prefs);
if (previousMode !== mode) {
this.logService.log('THEME_MODE_CHANGE', {
from: previousMode,
to: mode,
});
}
}
setTheme(theme: ThemeName): void {
const previousTheme = this.currentTheme();
setTheme(theme: ThemeId): void {
const previousTheme = this.prefs.theme;
this.prefs.theme = theme;
this.currentTheme.set(theme);
this.persist();
this.applyToDom();
this.prefs$.next(this.prefs);
// Log theme change
if (previousTheme !== theme) {
this.logService.log('THEME_CHANGE', {
from: previousTheme,
@@ -64,57 +117,57 @@ export class ThemeService {
}
}
toggleTheme(): void {
const previousTheme = this.currentTheme();
this.currentTheme.update(theme => (theme === 'light' ? 'dark' : 'light'));
setLanguage(language: Language): void {
const previousLanguage = this.prefs.language;
this.prefs.language = language;
this.persist();
this.prefs$.next(this.prefs);
// Log theme change
const newTheme = this.currentTheme();
if (previousTheme !== newTheme) {
this.logService.log('THEME_CHANGE', {
from: previousTheme,
to: newTheme,
if (previousLanguage !== language) {
this.logService.log('LANGUAGE_CHANGE', {
from: previousLanguage,
to: language,
});
}
}
private detectSystemTheme(): ThemeName {
if (typeof window === 'undefined') {
return 'light';
}
return window.matchMedia?.('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
toggleTheme(): void {
const isDark = this.resolveDark();
this.setTheme(isDark ? 'light' : 'dark');
}
private applyTheme(theme: ThemeName): void {
private resolveDark(): boolean {
if (this.prefs.mode === 'dark') return true;
if (this.prefs.mode === 'light') return false;
// mode === 'system'
return typeof window !== 'undefined' && window.matchMedia?.('(prefers-color-scheme: dark)').matches || false;
}
applyToDom(): void {
const root = this.document.documentElement;
root.setAttribute('data-theme', theme);
if (theme === 'dark') {
root.classList.add('dark');
} else {
root.classList.remove('dark');
}
const isDark = this.resolveDark();
root.classList.toggle('dark', isDark);
root.setAttribute('data-theme', this.prefs.theme);
}
private persist(theme: ThemeName): void {
private persist(): void {
try {
if (typeof window === 'undefined' || !window.localStorage) {
return;
}
window.localStorage.setItem(ThemeService.STORAGE_KEY, theme);
window.localStorage.setItem(ThemeService.STORAGE_KEY, JSON.stringify(this.prefs));
} catch {
// Ignore storage failures (private browsing, etc.)
}
}
private getStoredTheme(): ThemeName | null {
try {
if (typeof window === 'undefined' || !window.localStorage) {
return null;
}
const stored = window.localStorage.getItem(ThemeService.STORAGE_KEY) as ThemeName | null;
return stored === 'light' || stored === 'dark' ? stored : null;
} catch {
return null;
}
get prefsValue(): ThemePrefs {
return { ...this.prefs };
}
getCodeMirrorExtensions(): any[] {
const isDark = this.resolveDark();
return cm6ThemeFor(this.prefs.theme, isDark ? 'dark' : 'light');
}
}
@@ -7,10 +7,10 @@ import { MobileNavService } from '../../shared/services/mobile-nav.service';
standalone: true,
imports: [CommonModule],
template: `
<nav class="fixed bottom-0 left-0 right-0 bg-white dark:bg-gray-900 border-t border-gray-200 dark:border-gray-800 h-16 flex justify-around items-center z-50 safe-area-inset-bottom">
<nav class="fixed bottom-0 left-0 right-0 bg-card dark:bg-main border-t border-border dark:border-gray-800 h-16 flex justify-around items-center z-50 safe-area-inset-bottom">
<button *ngFor="let tab of tabs"
(click)="setActiveTab(tab.id)"
class="relative flex-1 flex flex-col items-center justify-center gap-1 text-xs py-2 px-1 text-gray-600 dark:text-gray-400 transition-all duration-200 active:scale-95 transform"
class="relative flex-1 flex flex-col items-center justify-center gap-1 text-xs py-2 px-1 text-muted dark:text-muted transition-all duration-200 active:scale-95 transform"
[class.text-nimbus-500]="mobileNav.activeTab() === tab.id"
[class.font-semibold]="mobileNav.activeTab() === tab.id">
<!-- Active indicator -->
@@ -24,7 +24,7 @@
stroke-linecap="round"
stroke-linejoin="round"
[class.text-red-500]="dirty() && !isSaving()"
[class.text-gray-400]="!dirty() && !isSaving()"
[class.text-muted]="!dirty() && !isSaving()"
[class.text-yellow-500]="isSaving()"
[class.animate-pulse]="isSaving()"
>
@@ -32,7 +32,7 @@
<polyline points="17 21 17 13 7 13 7 21"></polyline>
<polyline points="7 3 7 8 15 8"></polyline>
</svg>
<span class="text-xs font-medium" [class.text-red-500]="dirty() && !isSaving()" [class.text-gray-400]="!dirty() && !isSaving()" [class.text-yellow-500]="isSaving()">
<span class="text-xs font-medium" [class.text-red-500]="dirty() && !isSaving()" [class.text-muted]="!dirty() && !isSaving()" [class.text-yellow-500]="isSaving()">
{{isSaving() ? 'Sauvegarde...' : dirty() ? 'Non sauvegardé' : 'Sauvegardé'}}
</span>
</button>
@@ -15,13 +15,15 @@ import { EditorView, keymap, lineNumbers, highlightActiveLine, drawSelection, dr
import { EditorState, Compartment } from '@codemirror/state';
import { defaultKeymap, history, historyKeymap, indentWithTab } from '@codemirror/commands';
import { markdown, markdownLanguage } from '@codemirror/lang-markdown';
import { syntaxHighlighting, defaultHighlightStyle, bracketMatching, foldGutter } from '@codemirror/language';
import { syntaxHighlighting, defaultHighlightStyle, bracketMatching, foldGutter, LanguageDescription } from '@codemirror/language';
import { searchKeymap, highlightSelectionMatches } from '@codemirror/search';
import { autocompletion, completionKeymap, closeBrackets, closeBracketsKeymap } from '@codemirror/autocomplete';
import { lintKeymap } from '@codemirror/lint';
import { VaultService } from '../../../services/vault.service';
import { EditorStateService } from '../../../services/editor-state.service';
import { ToastService } from '../../shared/toast/toast.service';
import { ThemeService } from '../../core/services/theme.service';
import { EditorHighlightService } from '../../shared/editor/editor-highlight.service';
/**
* Composant d'édition Markdown avec CodeMirror 6
@@ -52,8 +54,7 @@ import { ToastService } from '../../shared/toast/toast.service';
<!-- Save Button -->
<button
type="button"
class="btn-editor"
[class.btn-editor--primary]="isDirty()"
class="btn btn-solid btn-sm"
[disabled]="isSaving()"
(click)="save()"
[attr.aria-label]="'Save (Ctrl+S)'">
@@ -71,8 +72,7 @@ import { ToastService } from '../../shared/toast/toast.service';
<!-- Wrap Toggle -->
<button
type="button"
class="btn-editor"
[class.btn-editor--active]="wordWrap()"
class="btn btn-outline btn-sm"
(click)="toggleWordWrap()"
[attr.aria-label]="'Toggle word wrap'">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
@@ -86,7 +86,7 @@ import { ToastService } from '../../shared/toast/toast.service';
<!-- Undo -->
<button
type="button"
class="btn-editor hidden sm:flex"
class="btn btn-ghost btn-sm hidden sm:flex"
(click)="undo()"
[attr.aria-label]="'Undo (Ctrl+Z)'">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
@@ -98,7 +98,7 @@ import { ToastService } from '../../shared/toast/toast.service';
<!-- Redo -->
<button
type="button"
class="btn-editor hidden sm:flex"
class="btn btn-ghost btn-sm hidden sm:flex"
(click)="redo()"
[attr.aria-label]="'Redo (Ctrl+Y)'">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
@@ -110,7 +110,7 @@ import { ToastService } from '../../shared/toast/toast.service';
<!-- Close/Cancel -->
<button
type="button"
class="btn-editor"
class="btn btn-outline btn-sm"
(click)="close()"
[attr.aria-label]="'Close editor (Esc)'">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
@@ -171,49 +171,7 @@ import { ToastService } from '../../shared/toast/toast.service';
flex-wrap: wrap;
}
.btn-editor {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0.375rem 0.75rem;
font-size: 0.875rem;
font-weight: 500;
color: var(--text-muted);
background: transparent;
border: 1px solid var(--border);
border-radius: 0.375rem;
cursor: pointer;
transition: all 0.15s ease;
white-space: nowrap;
}
.btn-editor:hover:not(:disabled) {
background: var(--bg-hover);
border-color: var(--border-hover);
color: var(--text);
}
.btn-editor:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.btn-editor--primary {
background: var(--brand);
color: white;
border-color: var(--brand);
}
.btn-editor--primary:hover:not(:disabled) {
background: var(--brand-hover);
border-color: var(--brand-hover);
}
.btn-editor--active {
background: var(--brand);
color: white;
border-color: var(--brand);
}
.markdown-editor__container {
flex: 1;
@@ -289,6 +247,7 @@ export class MarkdownEditorComponent implements OnInit, OnDestroy {
private vaultService = inject(VaultService);
private editorStateService = inject(EditorStateService);
private toastService = inject(ToastService);
private themeService = inject(ThemeService);
// Signals (public pour utilisation dans le template)
filePath = signal<string>('');
@@ -310,13 +269,75 @@ export class MarkdownEditorComponent implements OnInit, OnDestroy {
// CodeMirror
private editorView?: EditorView;
private wrapCompartment = new Compartment();
private themeCompartment = new Compartment();
private occurrencesCompartment = new Compartment();
private highlightFacetCompartment = new Compartment();
private autosaveTimer?: ReturnType<typeof setTimeout>;
private themeSubscription?: any;
private highlightService = inject(EditorHighlightService);
private readonly markdownCodeLanguages = [
LanguageDescription.of({
name: 'javascript',
alias: ['js', 'node', 'jsx', 'mjs', 'cjs'],
load: () => import('@codemirror/lang-javascript').then(m => m.javascript())
}),
LanguageDescription.of({
name: 'typescript',
alias: ['ts', 'tsx'],
load: () => import('@codemirror/lang-javascript').then(m => m.javascript({ typescript: true, jsx: true }))
}),
LanguageDescription.of({
name: 'json',
load: () => import('@codemirror/lang-json').then(m => m.json())
}),
LanguageDescription.of({
name: 'html',
alias: ['xml', 'svg'],
load: () => import('@codemirror/lang-html').then(m => m.html())
}),
LanguageDescription.of({
name: 'css',
alias: ['scss', 'less'],
load: () => import('@codemirror/lang-css').then(m => m.css())
}),
LanguageDescription.of({
name: 'python',
alias: ['py'],
load: () => import('@codemirror/lang-python').then(m => m.python())
}),
LanguageDescription.of({
name: 'go',
load: () => import('@codemirror/lang-go').then(m => m.go())
}),
LanguageDescription.of({
name: 'rust',
load: () => import('@codemirror/lang-rust').then(m => m.rust())
}),
LanguageDescription.of({
name: 'php',
load: () => import('@codemirror/lang-php').then(m => m.php())
}),
LanguageDescription.of({
name: 'sql',
load: () => import('@codemirror/lang-sql').then(m => m.sql())
}),
LanguageDescription.of({
name: 'java',
load: () => import('@codemirror/lang-java').then(m => m.java())
}),
LanguageDescription.of({
name: 'yaml',
alias: ['yml'],
load: () => import('@codemirror/lang-yaml').then(m => m.yaml())
})
];
constructor() {
// Détecter le thème
// S'abonner aux changements de thème
effect(() => {
if (typeof window !== 'undefined') {
const darkMode = document.documentElement.classList.contains('dark');
const darkMode = this.themeService.isDark();
this.isDarkTheme.set(darkMode);
}
});
@@ -324,7 +345,7 @@ export class MarkdownEditorComponent implements OnInit, OnDestroy {
ngOnInit(): void {
this.initializeEditor();
this.setupThemeObserver();
this.subscribeToThemeChanges();
}
ngOnDestroy(): void {
@@ -353,9 +374,12 @@ export class MarkdownEditorComponent implements OnInit, OnDestroy {
autocompletion(),
highlightSelectionMatches(),
foldGutter(),
syntaxHighlighting(defaultHighlightStyle),
markdown({ base: markdownLanguage }),
markdown({ base: markdownLanguage, addKeymap: true, codeLanguages: this.markdownCodeLanguages }),
...this.highlightService.extensions,
this.occurrencesCompartment.of([]),
this.wrapCompartment.of(this.wordWrap() ? EditorView.lineWrapping : []),
this.highlightFacetCompartment.of([]),
this.themeCompartment.of(this.themeService.getCodeMirrorExtensions()),
keymap.of([
saveCommand,
...defaultKeymap,
@@ -373,30 +397,6 @@ export class MarkdownEditorComponent implements OnInit, OnDestroy {
if (update.selectionSet) {
this.updateCursorPosition();
}
}),
EditorView.theme({
'&': {
height: '100%',
backgroundColor: this.isDarkTheme() ? '#1e293b' : '#ffffff'
},
'.cm-content': {
caretColor: this.isDarkTheme() ? '#60a5fa' : '#3b82f6',
color: this.isDarkTheme() ? '#e2e8f0' : '#1e293b'
},
'.cm-cursor': {
borderLeftColor: this.isDarkTheme() ? '#60a5fa' : '#3b82f6'
},
'.cm-activeLine': {
backgroundColor: this.isDarkTheme() ? '#334155' : '#f1f5f9'
},
'.cm-gutters': {
backgroundColor: this.isDarkTheme() ? '#1e293b' : '#f8fafc',
color: this.isDarkTheme() ? '#64748b' : '#94a3b8',
border: 'none'
},
'.cm-activeLineGutter': {
backgroundColor: this.isDarkTheme() ? '#334155' : '#e2e8f0'
}
})
]
});
@@ -409,52 +409,21 @@ export class MarkdownEditorComponent implements OnInit, OnDestroy {
this.updateCursorPosition();
}
private setupThemeObserver(): void {
if (typeof window === 'undefined') return;
const observer = new MutationObserver(() => {
const darkMode = document.documentElement.classList.contains('dark');
this.isDarkTheme.set(darkMode);
this.reconfigureTheme();
});
observer.observe(document.documentElement, {
attributes: true,
attributeFilter: ['class']
private subscribeToThemeChanges(): void {
this.themeSubscription = this.themeService.onPrefs$.subscribe(() => {
this.updateEditorTheme();
});
}
private reconfigureTheme(): void {
private updateEditorTheme(): void {
if (!this.editorView) return;
const newTheme = EditorView.theme({
'&': {
height: '100%',
backgroundColor: this.isDarkTheme() ? '#1e293b' : '#ffffff'
},
'.cm-content': {
caretColor: this.isDarkTheme() ? '#60a5fa' : '#3b82f6',
color: this.isDarkTheme() ? '#e2e8f0' : '#1e293b'
},
'.cm-cursor': {
borderLeftColor: this.isDarkTheme() ? '#60a5fa' : '#3b82f6'
},
'.cm-activeLine': {
backgroundColor: this.isDarkTheme() ? '#334155' : '#f1f5f9'
},
'.cm-gutters': {
backgroundColor: this.isDarkTheme() ? '#1e293b' : '#f8fafc',
color: this.isDarkTheme() ? '#64748b' : '#94a3b8',
border: 'none'
},
'.cm-activeLineGutter': {
backgroundColor: this.isDarkTheme() ? '#334155' : '#e2e8f0'
}
});
const newThemeExtensions = this.themeService.getCodeMirrorExtensions();
this.editorView.dispatch({
effects: []
effects: this.themeCompartment.reconfigure(newThemeExtensions)
});
// trigger a light re-measure so CSS variable based highlights pick up instantly
this.editorView.requestMeasure({ read: () => null, write: () => {} });
}
private onContentChange(newContent: string): void {
@@ -474,6 +443,28 @@ export class MarkdownEditorComponent implements OnInit, OnDestroy {
this.cursorCol.set(pos - line.from + 1);
}
// Public API: highlight helpers
highlightOccurrences(pattern: string | RegExp): void {
if (!this.editorView) return;
this.highlightService.applyOccurrences(this.editorView, this.occurrencesCompartment, pattern);
}
setHighlights(ranges: { from: number; to: number }[]): void {
if (!this.editorView) return;
this.highlightService.setRanges(this.editorView, ranges);
}
clearHighlights(): void {
if (!this.editorView) return;
this.highlightService.clearRanges(this.editorView);
}
setHighlightColor(cssColor: string | null): void {
if (!this.editorView) return;
const ext = cssColor ? [ (this.highlightService.facet() as any).of(cssColor) ] : [];
this.editorView.dispatch({ effects: this.highlightFacetCompartment.reconfigure(ext) });
}
toggleWordWrap(): void {
this.wordWrap.update(v => !v);
@@ -559,7 +550,9 @@ export class MarkdownEditorComponent implements OnInit, OnDestroy {
if (this.autosaveTimer) {
clearTimeout(this.autosaveTimer);
}
if (this.themeSubscription) {
this.themeSubscription.unsubscribe();
}
if (this.editorView) {
this.editorView.destroy();
}
@@ -11,13 +11,13 @@ import { TagFilterStore } from '../../core/stores/tag-filter.store';
imports: [CommonModule, ScrollableOverlayDirective],
template: `
<div class="h-full flex flex-col">
<div class="p-2 border-b border-gray-200 dark:border-gray-800 space-y-2">
<div class="p-2 border-b border-border dark:border-gray-800 space-y-2">
<div *ngIf="activeTag() as t" class="flex items-center gap-2 text-xs">
<span class="inline-flex items-center gap-1 rounded-full bg-slate-100 dark:bg-slate-800 text-slate-700 dark:text-slate-200 px-2 py-1">
<span class="inline-flex items-center gap-1 rounded-full bg-surface1 dark:bg-card text-main dark:text-main px-2 py-1">
<svg class="h-3.5 w-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M7 7h.01"/><path d="M3 7h5a2 2 0 0 1 1.414.586l7 7a2 2 0 0 1 0 2.828l-3.172 3.172a2 2 0 0 1-2.828 0l-7-7A2 2 0 0 1 3 12V7Z"/></svg>
Filtre: #{{ t }}
</span>
<button type="button" (click)="clearTagFilter()" class="rounded-full hover:bg-slate-500/10 dark:hover:bg-slate-200/10 w-6 h-6 inline-flex items-center justify-center" title="Effacer le filtre">
<button type="button" (click)="clearTagFilter()" class="rounded-full hover:bg-slate-500/10 dark:hover:bg-surface2/10 w-6 h-6 inline-flex items-center justify-center" title="Effacer le filtre">
<svg class="h-3.5 w-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 6 6 18"/><path d="m6 6 12 12"/></svg>
</button>
</div>
@@ -25,13 +25,13 @@ import { TagFilterStore } from '../../core/stores/tag-filter.store';
[value]="query()"
(input)="onQuery($any($event.target).value)"
placeholder="Rechercher..."
class="w-full rounded border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-900 px-3 py-2 text-sm" />
class="w-full rounded border border-border dark:border-border bg-card dark:bg-main px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring" />
</div>
<div class="flex-1 min-h-0 overflow-y-auto list-scroll" appScrollableOverlay>
<ul class="divide-y divide-gray-100 dark:divide-gray-800">
<li *ngFor="let n of filtered()" class="p-3 hover:bg-gray-50 dark:hover:bg-gray-800 cursor-pointer" (click)="openNote.emit(n.id)">
<li *ngFor="let n of filtered()" class="p-3 hover:bg-surface1 dark:hover:bg-card cursor-pointer" (click)="openNote.emit(n.id)">
<div class="text-sm font-semibold truncate">{{ n.title }}</div>
<div class="text-xs text-gray-500 truncate">{{ n.filePath }}</div>
<div class="text-xs text-muted truncate">{{ n.filePath }}</div>
</li>
</ul>
</div>
@@ -20,7 +20,7 @@ import { ScrollableOverlayDirective } from '../../shared/overlay-scrollbar/scrol
<!-- Bottom sheet -->
<div
class="fixed inset-x-0 bottom-0 z-[81] max-h-[85vh] rounded-t-2xl bg-white dark:bg-slate-900 shadow-2xl
class="fixed inset-x-0 bottom-0 z-[81] max-h-[85vh] rounded-t-2xl bg-card dark:bg-main shadow-2xl
transition-transform duration-300 ease-out
sm:left-auto sm:right-4 sm:bottom-4 sm:top-auto sm:w-[420px] sm:rounded-2xl sm:max-h-[80vh]"
[class.translate-y-full]="!isVisible"
@@ -31,20 +31,20 @@ import { ScrollableOverlayDirective } from '../../shared/overlay-scrollbar/scrol
(click)="$event.stopPropagation()">
<div class="flex items-center justify-center py-3 sm:py-2">
<div class="h-1.5 w-12 rounded-full bg-slate-300 dark:bg-slate-700"></div>
<div class="h-1.5 w-12 rounded-full bg-muted dark:bg-surface2"></div>
</div>
<div class="px-4 pb-4 overflow-y-auto max-h-[75vh] sm:max-h-[70vh]" appScrollableOverlay>
<h2 class="sr-only">Sommaire</h2>
<ul class="space-y-1 text-sm text-slate-800 dark:text-slate-200">
<ul class="space-y-1 text-sm text-main dark:text-main">
<li *ngFor="let h of headings; let i = index">
<button
type="button"
(click)="onGo(h.id)"
class="w-full text-left block px-3 py-2 rounded-lg hover:bg-slate-100 dark:hover:bg-slate-800 transition active:scale-[0.98]"
class="w-full text-left block px-3 py-2 rounded-lg hover:bg-surface1 dark:hover:bg-card transition active:scale-[0.98]"
[style.paddingLeft.rem]="(h.level - 1) * 0.75 + 0.75"
[attr.data-index]="i">
<span class="text-slate-400 dark:text-slate-500 mr-2 text-xs">{{ getLevelIndicator(h.level) }}</span>
<span class="text-muted dark:text-muted mr-2 text-xs">{{ getLevelIndicator(h.level) }}</span>
<span>{{ h.text }}</span>
</button>
</li>
@@ -1,7 +1,7 @@
<header class="note-header flex flex-col gap-2 min-w-0">
<div class="flex items-center gap-2">
<button type="button"
class="flex-shrink-0 inline-flex items-center justify-center w-8 h-8 rounded-lg border border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-800 text-slate-600 dark:text-slate-400 hover:bg-slate-100 dark:hover:bg-slate-700 hover:text-slate-900 dark:hover:text-slate-200 transition-all duration-150 shadow-sm"
class="flex-shrink-0 inline-flex items-center justify-center w-8 h-8 rounded-lg border border-border dark:border-border bg-card dark:bg-card text-muted dark:text-muted hover:bg-surface1 dark:hover:bg-surface2 hover:text-main dark:hover:text-main transition-all duration-150 shadow-sm"
aria-label="Copier le chemin"
title="Copier le chemin"
(click)="copyRequested.emit()">
@@ -0,0 +1,285 @@
.parameters-page {
min-height: 100vh;
background: var(--bg-main);
padding: 2rem 1rem;
}
.parameters-container {
max-width: 56rem;
margin: 0 auto;
}
/* Header */
.parameters-header {
margin-bottom: 2.5rem;
}
.parameters-title {
font-size: 2rem;
font-weight: 700;
color: var(--text-main);
margin: 0 0 0.5rem 0;
}
.parameters-subtitle {
font-size: 1rem;
color: var(--text-muted);
margin: 0;
}
/* Sections */
.parameters-section {
background: var(--card);
border: 1px solid var(--border);
border-radius: 0.75rem;
padding: 1.75rem;
margin-bottom: 1.5rem;
transition: all var(--transition-base);
}
.section-title {
display: flex;
align-items: center;
gap: 0.625rem;
font-size: 1.25rem;
font-weight: 600;
color: var(--text-main);
margin: 0 0 1.5rem 0;
}
.section-icon {
color: var(--primary);
flex-shrink: 0;
}
/* Grid Layout */
.grid-layout {
display: grid;
gap: 2rem;
}
.setting-group {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.setting-group.full-width {
grid-column: 1 / -1;
}
.setting-label {
font-size: 0.875rem;
font-weight: 600;
color: var(--text-main);
display: block;
}
.setting-hint {
font-size: 0.8125rem;
color: var(--text-muted);
margin: 0;
line-height: 1.5;
}
/* Button Group */
.button-group {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
}
.mode-button {
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.625rem 1rem;
background: var(--bg-muted);
border: 1.5px solid var(--border);
border-radius: 0.5rem;
color: var(--text-main);
font-size: 0.875rem;
font-weight: 500;
cursor: pointer;
transition: all var(--transition-base);
min-width: 6.5rem;
justify-content: center;
}
.mode-button:hover {
background: var(--bg-main);
border-color: var(--primary);
transform: translateY(-1px);
}
.mode-button.active {
background: var(--primary);
border-color: var(--primary);
color: white;
}
.button-icon {
flex-shrink: 0;
}
.button-text {
flex-shrink: 0;
}
/* Theme Grid */
.theme-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(10rem, 1fr));
gap: 1rem;
}
.theme-card {
display: flex;
flex-direction: column;
background: var(--bg-muted);
border: 2px solid var(--border);
border-radius: 0.625rem;
overflow: hidden;
cursor: pointer;
transition: all var(--transition-base);
padding: 0;
}
.theme-card:hover {
border-color: var(--primary);
transform: translateY(-2px);
box-shadow: 0 4px 12px var(--shadow-color);
}
.theme-card.active {
border-color: var(--primary);
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
}
.theme-preview {
height: 4rem;
width: 100%;
border-bottom: 1px solid var(--border);
}
.theme-info {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.75rem 1rem;
gap: 0.5rem;
}
.theme-name {
font-size: 0.875rem;
font-weight: 500;
color: var(--text-main);
}
.theme-check {
color: var(--primary);
flex-shrink: 0;
}
/* Stats Placeholder */
.stats-placeholder {
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
padding: 3rem 1.5rem;
background: var(--bg-muted);
border: 1px dashed var(--border);
border-radius: 0.75rem;
}
.stats-icon {
color: var(--text-muted);
margin-bottom: 1rem;
opacity: 0.5;
}
.stats-title {
font-size: 1.125rem;
font-weight: 600;
color: var(--text-main);
margin: 0 0 0.5rem 0;
}
.stats-description {
font-size: 0.875rem;
color: var(--text-muted);
margin: 0 0 1.5rem 0;
max-width: 32rem;
line-height: 1.6;
}
.stats-button {
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.625rem 1.25rem;
background: var(--bg-main);
border: 1px solid var(--border);
border-radius: 0.5rem;
color: var(--text-muted);
font-size: 0.875rem;
font-weight: 500;
cursor: not-allowed;
opacity: 0.6;
}
/* Footer */
.parameters-footer {
margin-top: 2rem;
padding-top: 1.5rem;
border-top: 1px solid var(--border);
}
.footer-note {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.8125rem;
color: var(--text-muted);
margin: 0;
}
.footer-note svg {
flex-shrink: 0;
opacity: 0.7;
}
/* Responsive */
@media (min-width: 640px) {
.parameters-page {
padding: 3rem 2rem;
}
.theme-grid {
grid-template-columns: repeat(4, 1fr);
}
}
@media (max-width: 639px) {
.parameters-title {
font-size: 1.5rem;
}
.parameters-section {
padding: 1.25rem;
}
.section-title {
font-size: 1.125rem;
}
.theme-grid {
grid-template-columns: repeat(2, 1fr);
}
.mode-button {
flex: 1;
min-width: auto;
}
}
@@ -0,0 +1,156 @@
<div class="parameters-page">
<div class="parameters-container">
<!-- Header -->
<header class="parameters-header">
<h1 class="parameters-title">Parameters</h1>
<p class="parameters-subtitle">Customize your ObsiViewer experience</p>
</header>
<!-- Appearance Section -->
<section class="parameters-section">
<h2 class="section-title">
<svg class="section-icon" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="5"/>
<line x1="12" y1="1" x2="12" y2="3"/>
<line x1="12" y1="21" x2="12" y2="23"/>
<line x1="4.22" y1="4.22" x2="5.64" y2="5.64"/>
<line x1="18.36" y1="18.36" x2="19.78" y2="19.78"/>
<line x1="1" y1="12" x2="3" y2="12"/>
<line x1="21" y1="12" x2="23" y2="12"/>
<line x1="4.22" y1="19.78" x2="5.64" y2="18.36"/>
<line x1="18.36" y1="5.64" x2="19.78" y2="4.22"/>
</svg>
Appearance
</h2>
<div class="grid-layout">
<!-- Mode Selection -->
<div class="setting-group">
<label class="setting-label">Display Mode</label>
<div class="button-group">
<button
*ngFor="let m of modes"
(click)="setMode(m)"
class="mode-button"
[class.active]="prefs().mode === m"
[attr.aria-label]="'Set mode to ' + m">
<svg *ngIf="m === 'system'" class="button-icon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="2" y="3" width="20" height="14" rx="2" ry="2"/>
<line x1="8" y1="21" x2="16" y2="21"/>
<line x1="12" y1="17" x2="12" y2="21"/>
</svg>
<svg *ngIf="m === 'light'" class="button-icon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="5"/>
<line x1="12" y1="1" x2="12" y2="3"/>
<line x1="12" y1="21" x2="12" y2="23"/>
<line x1="4.22" y1="4.22" x2="5.64" y2="5.64"/>
<line x1="18.36" y1="18.36" x2="19.78" y2="19.78"/>
<line x1="1" y1="12" x2="3" y2="12"/>
<line x1="21" y1="12" x2="23" y2="12"/>
<line x1="4.22" y1="19.78" x2="5.64" y2="18.36"/>
<line x1="18.36" y1="5.64" x2="19.78" y2="4.22"/>
</svg>
<svg *ngIf="m === 'dark'" class="button-icon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/>
</svg>
<span class="button-text">{{ m | titlecase }}</span>
</button>
</div>
<p class="setting-hint">Choose how ObsiViewer determines light or dark appearance</p>
</div>
<!-- Theme Selection -->
<div class="setting-group full-width">
<label class="setting-label">Color Theme</label>
<div class="theme-grid">
<button
*ngFor="let t of themes"
(click)="setTheme(t)"
class="theme-card"
[class.active]="prefs().theme === t"
[attr.aria-label]="'Set theme to ' + themeLabels[t]">
<div class="theme-preview" [style.background]="getThemePreview(t)"></div>
<div class="theme-info">
<span class="theme-name">{{ themeLabels[t] }}</span>
<svg *ngIf="prefs().theme === t" class="theme-check" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
<polyline points="20 6 9 17 4 12"/>
</svg>
</div>
</button>
</div>
<p class="setting-hint">Select a color scheme for the entire application including the editor</p>
</div>
</div>
</section>
<!-- Language Section -->
<section class="parameters-section">
<h2 class="section-title">
<svg class="section-icon" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="10"/>
<line x1="2" y1="12" x2="22" y2="12"/>
<path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/>
</svg>
Language
</h2>
<div class="setting-group">
<label class="setting-label">Interface Language</label>
<div class="button-group">
<button
*ngFor="let l of languages"
(click)="setLanguage(l)"
class="mode-button"
[class.active]="prefs().language === l"
[attr.aria-label]="'Set language to ' + l">
<span class="button-text">{{ l === 'fr' ? 'Français' : 'English' }}</span>
</button>
</div>
<p class="setting-hint">Interface language (i18n implementation coming soon)</p>
</div>
</section>
<!-- Vault Stats Section (Placeholder) -->
<section class="parameters-section">
<h2 class="section-title">
<svg class="section-icon" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<line x1="18" y1="20" x2="18" y2="10"/>
<line x1="12" y1="20" x2="12" y2="4"/>
<line x1="6" y1="20" x2="6" y2="14"/>
</svg>
Vault Statistics
</h2>
<div class="stats-placeholder">
<svg class="stats-icon" width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<path d="M3 3v18h18"/>
<path d="M18 17V9"/>
<path d="M13 17V5"/>
<path d="M8 17v-3"/>
</svg>
<h3 class="stats-title">Vault Analytics</h3>
<p class="stats-description">Coming soon: View detailed statistics about your vault including note count, tag usage, file sizes, and more.</p>
<button class="stats-button" disabled>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="18" y1="20" x2="18" y2="10"/>
<line x1="12" y1="20" x2="12" y2="4"/>
<line x1="6" y1="20" x2="6" y2="14"/>
</svg>
View Stats (Coming Soon)
</button>
</div>
</section>
<!-- Footer -->
<footer class="parameters-footer">
<p class="footer-note">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="10"/>
<line x1="12" y1="16" x2="12" y2="12"/>
<line x1="12" y1="8" x2="12.01" y2="8"/>
</svg>
Changes are saved automatically and applied immediately
</p>
</footer>
</div>
</div>
@@ -0,0 +1,80 @@
import { Component, inject, signal, effect } from '@angular/core';
import { CommonModule } from '@angular/common';
import { ThemeService, ThemeMode, ThemeId, Language } from '../../core/services/theme.service';
import { ToastService } from '../../shared/toast/toast.service';
@Component({
standalone: true,
selector: 'app-parameters',
imports: [CommonModule],
templateUrl: './parameters.page.html',
styleUrls: ['./parameters.page.css']
})
export class ParametersPage {
private themeService = inject(ThemeService);
private toastService = inject(ToastService);
// Reactive prefs
prefs = signal(this.themeService.prefsValue);
modes: ThemeMode[] = ['system', 'light', 'dark'];
themes: ThemeId[] = ['light', 'dark', 'obsidian', 'nord', 'notion', 'github', 'discord', 'monokai'];
languages: Language[] = ['fr', 'en'];
// Labels pour les thèmes
themeLabels: Record<ThemeId, string> = {
light: 'Pure White',
dark: 'Blue',
obsidian: 'Obsidian',
nord: 'Nord',
notion: 'Notion',
github: 'GitHub',
discord: 'Discord',
monokai: 'Monokai'
};
constructor() {
// S'abonner aux changements de préférences
effect(() => {
const subscription = this.themeService.onPrefs$.subscribe(prefs => {
this.prefs.set(prefs);
});
return () => subscription.unsubscribe();
});
}
setMode(mode: ThemeMode): void {
this.themeService.setMode(mode);
this.showToast('Mode changed successfully');
}
setTheme(theme: ThemeId): void {
this.themeService.setTheme(theme);
this.showToast('Theme changed successfully');
}
setLanguage(language: Language): void {
this.themeService.setLanguage(language);
this.showToast('Language changed successfully');
}
private showToast(message: string): void {
this.toastService.success(message, 2000);
}
getThemePreview(themeId: ThemeId): string {
// Couleurs de preview pour chaque thème
const previews: Record<ThemeId, string> = {
light: 'linear-gradient(135deg, #ffffff 0%, #f7f7f7 100%)',
dark: 'linear-gradient(135deg, #0f172a 0%, #1e293b 100%)',
obsidian: 'linear-gradient(135deg, #fafaf8 0%, #a89984 100%)',
nord: 'linear-gradient(135deg, #2e3440 0%, #88c0d0 100%)',
notion: 'linear-gradient(135deg, #ffffff 0%, #f7f7f5 100%)',
github: 'linear-gradient(135deg, #ffffff 0%, #0969da 100%)',
discord: 'linear-gradient(135deg, #313338 0%, #5865f2 100%)',
monokai: 'linear-gradient(135deg, #272822 0%, #a6e22e 100%)'
};
return previews[themeId];
}
}
@@ -22,49 +22,49 @@ interface QuickLinkCountsUi {
<div class="p-3">
<ul class="text-sm space-y-1">
<li>
<button (click)="select('all')" class="flex items-center gap-2 px-3 py-2 rounded-lg cursor-pointer hover:bg-slate-500/10 dark:hover:bg-slate-200/10 transition w-full text-left">
<button (click)="select('all')" class="flex items-center gap-2 px-3 py-2 rounded-lg cursor-pointer hover:bg-slate-500/10 dark:hover:bg-surface2/10 transition w-full text-left">
<span class="flex items-center gap-2"><span>🗂️</span> <span>All Pages</span></span>
<app-badge-count class="ml-auto" [count]="counts().all" color="slate"></app-badge-count>
</button>
</li>
<li>
<button (click)="select('favorites')" class="flex items-center gap-2 px-3 py-2 rounded-lg cursor-pointer hover:bg-slate-500/10 dark:hover:bg-slate-200/10 transition w-full text-left">
<button (click)="select('favorites')" class="flex items-center gap-2 px-3 py-2 rounded-lg cursor-pointer hover:bg-slate-500/10 dark:hover:bg-surface2/10 transition w-full text-left">
<span class="flex items-center gap-2"><span>❤️</span> <span>Favoris</span></span>
<app-badge-count class="ml-auto" [count]="counts().favorites" color="rose"></app-badge-count>
</button>
</li>
<li>
<button (click)="select('publish')" class="flex items-center gap-2 px-3 py-2 rounded-lg cursor-pointer hover:bg-slate-500/10 dark:hover:bg-slate-200/10 transition w-full text-left">
<button (click)="select('publish')" class="flex items-center gap-2 px-3 py-2 rounded-lg cursor-pointer hover:bg-slate-500/10 dark:hover:bg-surface2/10 transition w-full text-left">
<span class="flex items-center gap-2"><span>🌐</span> <span>Publish</span></span>
<app-badge-count class="ml-auto" [count]="counts().publish" color="green"></app-badge-count>
</button>
</li>
<li>
<button (click)="select('drafts')" class="flex items-center gap-2 px-3 py-2 rounded-lg cursor-pointer hover:bg-slate-500/10 dark:hover:bg-slate-200/10 transition w-full text-left">
<button (click)="select('drafts')" class="flex items-center gap-2 px-3 py-2 rounded-lg cursor-pointer hover:bg-slate-500/10 dark:hover:bg-surface2/10 transition w-full text-left">
<span class="flex items-center gap-2"><span>📝</span> <span>Draft</span></span>
<app-badge-count class="ml-auto" [count]="counts().drafts" color="emerald"></app-badge-count>
</button>
</li>
<li>
<button (click)="select('templates')" class="flex items-center gap-2 px-3 py-2 rounded-lg cursor-pointer hover:bg-slate-500/10 dark:hover:bg-slate-200/10 transition w-full text left">
<button (click)="select('templates')" class="flex items-center gap-2 px-3 py-2 rounded-lg cursor-pointer hover:bg-slate-500/10 dark:hover:bg-surface2/10 transition w-full text left">
<span class="flex items-center gap-2"><span>📑</span> <span>Template</span></span>
<app-badge-count class="ml-auto" [count]="counts().templates" color="amber"></app-badge-count>
</button>
</li>
<li>
<button (click)="select('tasks')" class="flex items-center gap-2 px-3 py-2 rounded-lg cursor-pointer hover:bg-slate-500/10 dark:hover:bg-slate-200/10 transition w-full text-left">
<button (click)="select('tasks')" class="flex items-center gap-2 px-3 py-2 rounded-lg cursor-pointer hover:bg-slate-500/10 dark:hover:bg-surface2/10 transition w-full text-left">
<span class="flex items-center gap-2"><span>🗒️</span> <span>Task</span></span>
<app-badge-count class="ml-auto" [count]="counts().tasks" color="indigo"></app-badge-count>
</button>
</li>
<li>
<button (click)="select('private')" class="flex items-center gap-2 px-3 py-2 rounded-lg cursor-pointer hover:bg-slate-500/10 dark:hover:bg-slate-200/10 transition w-full text-left">
<button (click)="select('private')" class="flex items-center gap-2 px-3 py-2 rounded-lg cursor-pointer hover:bg-slate-500/10 dark:hover:bg-surface2/10 transition w-full text-left">
<span class="flex items-center gap-2"><span>🔒</span> <span>Private</span></span>
<app-badge-count class="ml-auto" [count]="counts().private" color="purple"></app-badge-count>
</button>
</li>
<li>
<button (click)="select('archive')" class="flex items-center gap-2 px-3 py-2 rounded-lg cursor-pointer hover:bg-slate-500/10 dark:hover:bg-slate-200/10 transition w-full text-left">
<button (click)="select('archive')" class="flex items-center gap-2 px-3 py-2 rounded-lg cursor-pointer hover:bg-slate-500/10 dark:hover:bg-surface2/10 transition w-full text-left">
<span class="flex items-center gap-2"><span>🗃️</span> <span>Archive</span></span>
<app-badge-count class="ml-auto" [count]="counts().archive" color="stone"></app-badge-count>
</button>
@@ -14,15 +14,15 @@ import { TrashExplorerComponent } from '../../layout/sidebar/trash/trash-explore
standalone: true,
imports: [CommonModule, FileExplorerComponent, QuickLinksComponent, ScrollableOverlayDirective, TrashExplorerComponent],
template: `
<aside class="fixed left-0 top-0 bottom-0 w-80 max-w-[80vw] bg-white dark:bg-gray-900 shadow-2xl z-40 transform transition-all duration-300 ease-out flex flex-col"
<aside class="fixed left-0 top-0 bottom-0 w-80 max-w-[80vw] bg-card dark:bg-main shadow-2xl z-40 transform transition-all duration-300 ease-out flex flex-col"
[class.-translate-x-full]="!mobileNav.sidebarOpen()"
[class.translate-x-0]="mobileNav.sidebarOpen()">
<!-- Header -->
<div class="flex items-center justify-between p-4 border-b border-gray-200 dark:border-gray-800 bg-gradient-to-r from-nimbus-50 to-transparent dark:from-nimbus-900/20">
<div class="flex items-center justify-between p-4 border-b border-border dark:border-gray-800 bg-gradient-to-r from-nimbus-50 to-transparent dark:from-nimbus-900/20">
<h2 class="text-lg font-semibold truncate">{{ vaultName || 'ObsiViewer' }}</h2>
<button
(click)="mobileNav.toggleSidebar()"
class="p-2 rounded-full hover:bg-gray-100 dark:hover:bg-gray-800 transition-all active:scale-95 transform flex-shrink-0">
class="p-2 rounded-full hover:bg-surface1 dark:hover:bg-card transition-all active:scale-95 transform flex-shrink-0">
✕
</button>
</div>
@@ -30,27 +30,27 @@ import { TrashExplorerComponent } from '../../layout/sidebar/trash/trash-explore
<!-- Scrollable Content -->
<div class="flex-1 overflow-y-auto min-h-0" appScrollableOverlay>
<!-- Section Tests (dev-only) -->
<section *ngIf="env.features.showTestSection" class="border-b border-gray-200 dark:border-gray-800">
<button class="w-full flex items-center justify-between px-4 py-3 text-sm font-semibold hover:bg-gray-100 dark:hover:bg-gray-800 active:bg-gray-200 dark:active:bg-gray-700 transition-colors"
<section *ngIf="env.features.showTestSection" class="border-b border-border dark:border-gray-800">
<button class="w-full flex items-center justify-between px-4 py-3 text-sm font-semibold hover:bg-surface1 dark:hover:bg-card active:bg-surface2 dark:active:bg-gray-700 transition-colors"
(click)="open.tests = !open.tests">
<span>Section Tests</span>
<span class="text-xs text-gray-500 transition-transform duration-200" [class.rotate-90]="!open.tests">{{ open.tests ? '▾' : '▸' }}</span>
<span class="text-xs text-muted transition-transform duration-200" [class.rotate-90]="!open.tests">{{ open.tests ? '▾' : '▸' }}</span>
</button>
<div *ngIf="open.tests" class="px-3 py-2">
<button
(click)="onMarkdownPlaygroundClick()"
class="w-full text-left block text-sm px-3 py-2 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-800 active:bg-gray-200 dark:active:bg-gray-700 text-gray-700 dark:text-gray-300 hover:text-gray-900 dark:hover:text-gray-100 transition-all active:scale-[0.98] transform">
class="w-full text-left block text-sm px-3 py-2 rounded-lg hover:bg-surface1 dark:hover:bg-card active:bg-surface2 dark:active:bg-gray-700 text-main dark:text-main hover:text-main dark:hover:text-gray-100 transition-all active:scale-[0.98] transform">
🧪 Markdown Playground
</button>
</div>
</section>
<!-- Quick Links accordion -->
<section class="border-b border-gray-200 dark:border-gray-800">
<button class="w-full flex items-center justify-between px-4 py-3 text-sm font-semibold hover:bg-gray-100 dark:hover:bg-gray-800 active:bg-gray-200 dark:active:bg-gray-700 transition-colors"
<section class="border-b border-border dark:border-gray-800">
<button class="w-full flex items-center justify-between px-4 py-3 text-sm font-semibold hover:bg-surface1 dark:hover:bg-card active:bg-surface2 dark:active:bg-gray-700 transition-colors"
(click)="open.quick = !open.quick">
<span>Quick Links</span>
<span class="text-xs text-gray-500 transition-transform duration-200" [class.rotate-90]="!open.quick">{{ open.quick ? '▾' : '▸' }}</span>
<span class="text-xs text-muted transition-transform duration-200" [class.rotate-90]="!open.quick">{{ open.quick ? '▾' : '▸' }}</span>
</button>
<div *ngIf="open.quick" class="pt-1">
<app-quick-links (quickLinkSelected)="onQuickLink($event)"></app-quick-links>
@@ -58,11 +58,11 @@ import { TrashExplorerComponent } from '../../layout/sidebar/trash/trash-explore
</section>
<!-- Folders accordion -->
<section class="border-b border-gray-200 dark:border-gray-800">
<button class="w-full flex items-center justify-between px-4 py-3 text-sm font-semibold hover:bg-gray-100 dark:hover:bg-gray-800 active:bg-gray-200 dark:active:bg-gray-700 transition-colors"
<section class="border-b border-border dark:border-gray-800">
<button class="w-full flex items-center justify-between px-4 py-3 text-sm font-semibold hover:bg-surface1 dark:hover:bg-card active:bg-surface2 dark:active:bg-gray-700 transition-colors"
(click)="open.folders = !open.folders">
<span>Folders</span>
<span class="text-xs text-gray-500 transition-transform duration-200" [class.rotate-90]="!open.folders">{{ open.folders ? '▾' : '▸' }}</span>
<span class="text-xs text-muted transition-transform duration-200" [class.rotate-90]="!open.folders">{{ open.folders ? '▾' : '▸' }}</span>
</button>
<div *ngIf="open.folders" class="px-1 py-1">
<app-file-explorer [nodes]="nodes" [selectedNoteId]="selectedNoteId" [foldersOnly]="true" (folderSelected)="onFolder($event)" (fileSelected)="onSelect($event)"></app-file-explorer>
@@ -70,45 +70,45 @@ import { TrashExplorerComponent } from '../../layout/sidebar/trash/trash-explore
</section>
<!-- Tags accordion -->
<section class="border-b border-gray-200 dark:border-gray-800">
<button class="w-full flex items-center justify-between px-4 py-3 text-sm font-semibold hover:bg-gray-100 dark:hover:bg-gray-800 active:bg-gray-200 dark:active:bg-gray-700 transition-colors"
<section class="border-b border-border dark:border-gray-800">
<button class="w-full flex items-center justify-between px-4 py-3 text-sm font-semibold hover:bg-surface1 dark:hover:bg-card active:bg-surface2 dark:active:bg-gray-700 transition-colors"
(click)="open.tags = !open.tags">
<span>Tags</span>
<span class="text-xs text-gray-500 transition-transform duration-200" [class.rotate-90]="!open.tags">{{ open.tags ? '▾' : '▸' }}</span>
<span class="text-xs text-muted transition-transform duration-200" [class.rotate-90]="!open.tags">{{ open.tags ? '▾' : '▸' }}</span>
</button>
<div *ngIf="open.tags" class="px-2 py-2">
<ul class="space-y-1 text-sm">
<li *ngFor="let t of tags" class="flex items-center gap-2">
<button (click)="onTagSelected(t.name)" class="flex-1 text-left px-3 py-2 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-800 active:bg-gray-200 dark:active:bg-gray-700 transition-all active:scale-[0.98] transform truncate">
<button (click)="onTagSelected(t.name)" class="flex-1 text-left px-3 py-2 rounded-lg hover:bg-surface1 dark:hover:bg-card active:bg-surface2 dark:active:bg-gray-700 transition-all active:scale-[0.98] transform truncate">
<span>🏷️</span>
<span class="ml-1">{{ t.name }}</span>
</button>
<span class="text-xs text-gray-500 font-medium min-w-[2rem] text-right">{{ t.count }}</span>
<span class="text-xs text-muted font-medium min-w-[2rem] text-right">{{ t.count }}</span>
</li>
</ul>
</div>
</section>
<!-- Trash accordion -->
<section class="border-b border-gray-200 dark:border-gray-800">
<button class="w-full flex items-center justify-between px-4 py-3 text-sm font-semibold hover:bg-gray-100 dark:hover:bg-gray-800 active:bg-gray-200 dark:active:bg-gray-700 transition-colors"
<section class="border-b border-border dark:border-gray-800">
<button class="w-full flex items-center justify-between px-4 py-3 text-sm font-semibold hover:bg-surface1 dark:hover:bg-card active:bg-surface2 dark:active:bg-gray-700 transition-colors"
(click)="open.trash = !open.trash; onFolder('.trash')">
<span class="flex items-center gap-2">Trash</span>
<span class="text-xs text-gray-500 transition-transform duration-200" [class.rotate-90]="!open.trash">{{ open.trash ? '▾' : '▸' }}</span>
<span class="text-xs text-muted transition-transform duration-200" [class.rotate-90]="!open.trash">{{ open.trash ? '▾' : '▸' }}</span>
</button>
<div *ngIf="open.trash" class="px-1 py-2">
<ng-container *ngIf="trashHasContent(); else emptyTrash">
<app-trash-explorer (folderSelected)="onFolder($event)"></app-trash-explorer>
</ng-container>
<ng-template #emptyTrash>
<div class="px-3 py-2 text-slate-400 text-sm">La corbeille est vide</div>
<div class="px-3 py-2 text-muted text-sm">La corbeille est vide</div>
</ng-template>
</div>
</section>
</div>
<!-- Footer -->
<div class="h-14 border-t border-gray-200 dark:border-gray-800 flex items-center justify-between px-4 text-xs text-gray-500 bg-gray-50 dark:bg-gray-900/50">
<div class="h-14 border-t border-border dark:border-gray-800 flex items-center justify-between px-4 text-xs text-muted bg-surface1 dark:bg-main/50">
<span>ObsiViewer</span>
<span class="text-[10px] opacity-60">v1.0</span>
</div>
@@ -16,35 +16,35 @@ import { VaultService } from '../../../services/vault.service';
template: `
<div class="h-full flex flex-col overflow-hidden select-none">
<!-- Header -->
<div class="h-12 flex items-center justify-between px-3 border-b border-gray-200 dark:border-gray-800">
<div class="h-12 flex items-center justify-between px-3 border-b border-border dark:border-gray-800">
<div class="text-sm font-semibold truncate">{{ vaultName }} - ObsiViewer</div>
<button (click)="toggleSidebarRequest.emit()" class="p-2 rounded hover:bg-gray-100 dark:hover:bg-gray-800" title="Hide Sidebar">⟨⟨</button>
<button (click)="toggleSidebarRequest.emit()" class="p-2 rounded hover:bg-surface1 dark:hover:bg-card" title="Hide Sidebar">⟨⟨</button>
</div>
<!-- Content (scroll) -->
<div class="flex-1 overflow-y-auto min-h-0" appScrollableOverlay>
<!-- Section Tests (dev-only) -->
<section *ngIf="env.features.showTestSection" class="border-b border-gray-200 dark:border-gray-800">
<button class="w-full flex items-center justify-between px-3 py-2 text-sm font-semibold hover:bg-gray-100 dark:hover:bg-gray-800"
<section *ngIf="env.features.showTestSection" class="border-b border-border dark:border-gray-800">
<button class="w-full flex items-center justify-between px-3 py-2 text-sm font-semibold hover:bg-surface1 dark:hover:bg-card"
(click)="open.tests = !open.tests">
<span>Section Tests</span>
<span class="text-xs text-gray-500">{{ open.tests ? '▾' : '▸' }}</span>
<span class="text-xs text-muted">{{ open.tests ? '▾' : '▸' }}</span>
</button>
<div *ngIf="open.tests" class="px-3 py-2">
<button
(click)="onMarkdownPlaygroundClick()"
class="w-full text-left block text-sm px-2 py-1.5 rounded hover:bg-gray-100 dark:hover:bg-gray-800 text-gray-700 dark:text-gray-300 hover:text-gray-900 dark:hover:text-gray-100">
class="w-full text-left block text-sm px-2 py-1.5 rounded hover:bg-surface1 dark:hover:bg-card text-main dark:text-main hover:text-main dark:hover:text-gray-100">
Markdown Playground
</button>
</div>
</section>
<!-- Quick Links accordion -->
<section class="border-b border-gray-200 dark:border-gray-800">
<button class="w-full flex items-center justify-between px-3 py-2 text-sm font-semibold hover:bg-gray-100 dark:hover:bg-gray-800"
<section class="border-b border-border dark:border-gray-800">
<button class="w-full flex items-center justify-between px-3 py-2 text-sm font-semibold hover:bg-surface1 dark:hover:bg-card"
(click)="open.quick = !open.quick">
<span>Quick Links</span>
<span class="text-xs text-gray-500">{{ open.quick ? '▾' : '▸' }}</span>
<span class="text-xs text-muted">{{ open.quick ? '▾' : '▸' }}</span>
</button>
<div *ngIf="open.quick" class="pt-1">
<app-quick-links (quickLinkSelected)="onQuickLink($event)"></app-quick-links>
@@ -52,11 +52,11 @@ import { VaultService } from '../../../services/vault.service';
</section>
<!-- Folders accordion -->
<section class="border-b border-gray-200 dark:border-gray-800">
<button class="w-full flex items-center justify-between px-3 py-2 text-sm font-semibold hover:bg-gray-100 dark:hover:bg-gray-800"
<section class="border-b border-border dark:border-gray-800">
<button class="w-full flex items-center justify-between px-3 py-2 text-sm font-semibold hover:bg-surface1 dark:hover:bg-card"
(click)="toggleFoldersSection()">
<span>Folders</span>
<span class="text-xs text-gray-500">{{ open.folders ? '▾' : '▸' }}</span>
<span class="text-xs text-muted">{{ open.folders ? '▾' : '▸' }}</span>
</button>
<div *ngIf="open.folders" class="px-1 py-1">
<app-file-explorer
@@ -70,31 +70,31 @@ import { VaultService } from '../../../services/vault.service';
</section>
<!-- Tags accordion -->
<section class="border-b border-gray-200 dark:border-gray-800">
<button class="w-full flex items-center justify-between px-3 py-2 text-sm font-semibold hover:bg-gray-100 dark:hover:bg-gray-800"
<section class="border-b border-border dark:border-gray-800">
<button class="w-full flex items-center justify-between px-3 py-2 text-sm font-semibold hover:bg-surface1 dark:hover:bg-card"
(click)="open.tags = !open.tags">
<span>Tags</span>
<span class="text-xs text-gray-500">{{ open.tags ? '▾' : '▸' }}</span>
<span class="text-xs text-muted">{{ open.tags ? '▾' : '▸' }}</span>
</button>
<div *ngIf="open.tags" class="px-2 py-2">
<ul class="space-y-0.5 text-sm">
<li *ngFor="let t of tags" class="flex items-center gap-2">
<button (click)="tagSelected.emit(t.name)" class="flex-1 text-left px-2 py-1 rounded hover:bg-gray-100 dark:hover:bg-gray-800 truncate">
<button (click)="tagSelected.emit(t.name)" class="flex-1 text-left px-2 py-1 rounded hover:bg-surface1 dark:hover:bg-card truncate">
<span>🏷️</span>
<span class="ml-1">{{ t.name }}</span>
</button>
<span class="text-xs text-gray-500">{{ t.count }}</span>
<span class="text-xs text-muted">{{ t.count }}</span>
</li>
</ul>
</div>
</section>
<!-- Trash accordion -->
<section class="border-b border-gray-200 dark:border-gray-800">
<button class="w-full flex items-center justify-between px-3 py-2 text-sm font-semibold hover:bg-gray-100 dark:hover:bg-gray-800"
<section class="border-b border-border dark:border-gray-800">
<button class="w-full flex items-center justify-between px-3 py-2 text-sm font-semibold hover:bg-surface1 dark:hover:bg-card"
(click)="toggleTrashSection()">
<span class="flex items-center gap-2">Trash</span>
<span class="text-xs text-gray-500">{{ open.trash ? '▾' : '▸' }}</span>
<span class="text-xs text-muted">{{ open.trash ? '▾' : '▸' }}</span>
</button>
<div *ngIf="open.trash" class="px-1 py-2">
<ng-container *ngIf="trashHasContent(); else emptyTrash">
@@ -108,14 +108,14 @@ import { VaultService } from '../../../services/vault.service';
</app-file-explorer>
</ng-container>
<ng-template #emptyTrash>
<div class="px-3 py-2 text-slate-400 text-sm">La corbeille est vide</div>
<div class="px-3 py-2 text-muted text-sm">La corbeille est vide</div>
</ng-template>
</div>
</section>
</div>
<!-- Footer placeholder -->
<div class="h-14 border-t border-gray-200 dark:border-gray-800 flex items-center px-3 text-xs text-gray-500">ObsiViewer</div>
<div class="h-14 border-t border-border dark:border-gray-800 flex items-center px-3 text-xs text-muted">ObsiViewer</div>
</div>
`
})
@@ -13,11 +13,11 @@ const DEFAULT_MD_PATH_ABS = '/assets/samples/markdown-playground.md';
standalone: true,
imports: [CommonModule, FormsModule, HttpClientModule, MarkdownViewerComponent],
template: `
<div class="h-full flex flex-col bg-gray-50 dark:bg-gray-900">
<div class="h-full flex flex-col bg-surface1 dark:bg-main">
<!-- Header -->
<header class="bg-white dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700 px-6 py-4">
<h1 class="text-2xl font-semibold text-gray-900 dark:text-gray-100">Markdown Playground</h1>
<p class="text-sm text-gray-600 dark:text-gray-400 mt-1">
<header class="bg-card dark:bg-card border-b border-border dark:border-border px-6 py-4">
<h1 class="text-2xl font-semibold text-main dark:text-gray-100">Markdown Playground</h1>
<p class="text-sm text-muted dark:text-muted mt-1">
Page de test interne pour valider tous les formatages Markdown supportés par ObsiViewer.
</p>
</header>
@@ -25,34 +25,34 @@ const DEFAULT_MD_PATH_ABS = '/assets/samples/markdown-playground.md';
<!-- Content -->
<div class="flex-1 flex gap-4 p-4 overflow-hidden">
<!-- Editor Panel -->
<div class="flex-1 flex flex-col bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 overflow-hidden">
<div class="px-4 py-2 border-b border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-750">
<h2 class="text-sm font-semibold text-gray-700 dark:text-gray-300">Markdown Source</h2>
<div class="flex-1 flex flex-col bg-card dark:bg-card rounded-lg border border-border dark:border-border overflow-hidden">
<div class="px-4 py-2 border-b border-border dark:border-border bg-surface1 dark:bg-gray-750">
<h2 class="text-sm font-semibold text-main dark:text-main">Markdown Source</h2>
</div>
<textarea
[ngModel]="sample()"
(ngModelChange)="sample.set($event)"
class="flex-1 p-4 font-mono text-sm bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 resize-none focus:outline-none"
class="flex-1 p-4 font-mono text-sm bg-card dark:bg-card text-main dark:text-gray-100 resize-none focus:outline-none"
placeholder="Entrez votre Markdown ici..."
spellcheck="false"
></textarea>
</div>
<!-- Preview Panel -->
<div class="flex-1 flex flex-col bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 overflow-hidden">
<div class="px-4 py-2 border-b border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-750 flex items-center justify-between">
<h2 class="text-sm font-semibold text-gray-700 dark:text-gray-300">Preview</h2>
<div class="flex-1 flex flex-col bg-card dark:bg-card rounded-lg border border-border dark:border-border overflow-hidden">
<div class="px-4 py-2 border-b border-border dark:border-border bg-surface1 dark:bg-gray-750 flex items-center justify-between">
<h2 class="text-sm font-semibold text-main dark:text-main">Preview</h2>
<div class="flex items-center gap-2">
<button
(click)="toggleViewMode()"
class="text-xs px-3 py-1 rounded bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600 text-gray-700 dark:text-gray-300"
class="text-xs px-3 py-1 rounded bg-surface2 dark:bg-surface2 hover:bg-muted dark:hover:bg-gray-600 text-main dark:text-main"
title="Toggle between inline and component view"
>
{{ useComponentView() ? 'Inline View' : 'Component View' }}
</button>
<button
(click)="resetToDefault()"
class="text-xs px-3 py-1 rounded bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600 text-gray-700 dark:text-gray-300"
class="text-xs px-3 py-1 rounded bg-surface2 dark:bg-surface2 hover:bg-muted dark:hover:bg-gray-600 text-main dark:text-main"
>
Reset
</button>
@@ -71,7 +71,7 @@ const DEFAULT_MD_PATH_ABS = '/assets/samples/markdown-playground.md';
<!-- Inline View -->
<div
*ngIf="!useComponentView()"
class="prose prose-slate dark:prose-invert max-w-none"
class="md-view"
[innerHTML]="renderedHtml()"
></div>
</div>
+4 -4
View File
@@ -37,7 +37,7 @@ interface TooltipData {
imports: [CommonModule],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<div class="relative w-full h-full bg-gray-50 dark:bg-gray-900">
<div class="relative w-full h-full bg-surface1 dark:bg-main">
<canvas
#canvas
class="w-full h-full cursor-grab active:cursor-grabbing"
@@ -52,13 +52,13 @@ interface TooltipData {
<!-- Tooltip -->
@if (tooltip()) {
<div
class="absolute pointer-events-none bg-gray-800 dark:bg-gray-700 text-white text-xs px-3 py-2 rounded-lg shadow-lg z-50"
class="absolute pointer-events-none bg-card dark:bg-surface2 text-white text-xs px-3 py-2 rounded-lg shadow-lg z-50"
[style.left.px]="tooltip()!.x + 10"
[style.top.px]="tooltip()!.y - 10">
<div class="font-semibold">{{ tooltip()!.node.title }}</div>
<div class="text-gray-300 text-xs">{{ tooltip()!.node.path }}</div>
@if (tooltip()!.node.tags.length > 0) {
<div class="text-gray-400 text-xs mt-1">
<div class="text-muted text-xs mt-1">
{{ tooltip()!.node.tags.join(', ') }}
</div>
}
@@ -66,7 +66,7 @@ interface TooltipData {
}
<!-- Info overlay -->
<div class="absolute top-4 left-4 bg-white dark:bg-gray-800 px-3 py-2 rounded-lg shadow-md text-xs text-gray-700 dark:text-gray-300 pointer-events-none">
<div class="absolute top-4 left-4 bg-card dark:bg-card px-3 py-2 rounded-lg shadow-md text-xs text-main dark:text-main pointer-events-none">
<div><strong>Nodes:</strong> {{ nodes().length }}</div>
<div><strong>Links:</strong> {{ links().length }}</div>
</div>
+4 -4
View File
@@ -14,7 +14,7 @@ import { GroupLegendItem } from './graph-data.types';
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
@if (items().length > 0) {
<div class="flex flex-wrap gap-2 p-3 bg-white dark:bg-gray-800 rounded-lg shadow-md">
<div class="flex flex-wrap gap-2 p-3 bg-card dark:bg-card rounded-lg shadow-md">
@for (item of items(); track item.groupIndex) {
<button
type="button"
@@ -22,7 +22,7 @@ import { GroupLegendItem } from './graph-data.types';
[class.opacity-50]="!item.active"
[class.ring-2]="!item.active"
[class.ring-gray-400]="!item.active"
class="flex items-center gap-2 px-3 py-1.5 rounded-full bg-gray-50 dark:bg-gray-700 hover:bg-gray-100 dark:hover:bg-gray-600 transition-all cursor-pointer group">
class="flex items-center gap-2 px-3 py-1.5 rounded-full bg-surface1 dark:bg-surface2 hover:bg-surface1 dark:hover:bg-gray-600 transition-all cursor-pointer group">
<!-- Color chip -->
<div
@@ -31,12 +31,12 @@ import { GroupLegendItem } from './graph-data.types';
</div>
<!-- Query text -->
<span class="text-xs font-medium text-gray-700 dark:text-gray-300 group-hover:text-gray-900 dark:group-hover:text-gray-100">
<span class="text-xs font-medium text-main dark:text-main group-hover:text-main dark:group-hover:text-gray-100">
{{ item.query }}
</span>
<!-- Count badge -->
<span class="text-xs font-semibold px-1.5 py-0.5 rounded bg-gray-200 dark:bg-gray-600 text-gray-700 dark:text-gray-300">
<span class="text-xs font-semibold px-1.5 py-0.5 rounded bg-surface2 dark:bg-gray-600 text-main dark:text-main">
{{ item.count }}
</span>
</button>
@@ -16,15 +16,15 @@ import { GraphConfig, GRAPH_CONFIG_BOUNDS } from '../../graph-settings.types';
type="checkbox"
[checked]="config().showArrow"
(change)="onToggleChange('showArrow', $event)"
class="w-4 h-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700 cursor-pointer">
<span class="text-sm text-gray-700 dark:text-gray-300 group-hover:text-gray-900 dark:group-hover:text-gray-100 select-none">Arrows</span>
class="w-4 h-4 rounded border-border text-blue-600 focus:ring-blue-500 dark:border-border dark:bg-surface2 cursor-pointer">
<span class="text-sm text-main dark:text-main group-hover:text-main dark:group-hover:text-gray-100 select-none">Arrows</span>
</label>
<!-- Text fade threshold slider -->
<div>
<label class="flex items-center justify-between text-sm text-gray-700 dark:text-gray-300 mb-2">
<label class="flex items-center justify-between text-sm text-main dark:text-main mb-2">
<span>Text fade threshold</span>
<span class="font-mono text-xs bg-gray-100 dark:bg-gray-800 px-2 py-0.5 rounded">{{ formatNumber(config().textFadeMultiplier, 1) }}</span>
<span class="font-mono text-xs bg-surface1 dark:bg-card px-2 py-0.5 rounded">{{ formatNumber(config().textFadeMultiplier, 1) }}</span>
</label>
<input
type="range"
@@ -33,8 +33,8 @@ import { GraphConfig, GRAPH_CONFIG_BOUNDS } from '../../graph-settings.types';
[step]="bounds.textFadeMultiplier.step"
[value]="config().textFadeMultiplier"
(input)="onSliderChange('textFadeMultiplier', $event)"
class="w-full h-2 bg-gray-200 dark:bg-gray-700 rounded-lg appearance-none cursor-pointer accent-blue-600">
<div class="flex justify-between text-xs text-gray-500 dark:text-gray-400 mt-1">
class="w-full h-2 bg-surface2 dark:bg-surface2 rounded-lg appearance-none cursor-pointer accent-blue-600">
<div class="flex justify-between text-xs text-muted dark:text-muted mt-1">
<span>{{ bounds.textFadeMultiplier.min }}</span>
<span>{{ bounds.textFadeMultiplier.max }}</span>
</div>
@@ -42,9 +42,9 @@ import { GraphConfig, GRAPH_CONFIG_BOUNDS } from '../../graph-settings.types';
<!-- Node size slider -->
<div>
<label class="flex items-center justify-between text-sm text-gray-700 dark:text-gray-300 mb-2">
<label class="flex items-center justify-between text-sm text-main dark:text-main mb-2">
<span>Node size</span>
<span class="font-mono text-xs bg-gray-100 dark:bg-gray-800 px-2 py-0.5 rounded">{{ formatNumber(config().nodeSizeMultiplier, 2) }}</span>
<span class="font-mono text-xs bg-surface1 dark:bg-card px-2 py-0.5 rounded">{{ formatNumber(config().nodeSizeMultiplier, 2) }}</span>
</label>
<input
type="range"
@@ -53,8 +53,8 @@ import { GraphConfig, GRAPH_CONFIG_BOUNDS } from '../../graph-settings.types';
[step]="bounds.nodeSizeMultiplier.step"
[value]="config().nodeSizeMultiplier"
(input)="onSliderChange('nodeSizeMultiplier', $event)"
class="w-full h-2 bg-gray-200 dark:bg-gray-700 rounded-lg appearance-none cursor-pointer accent-blue-600">
<div class="flex justify-between text-xs text-gray-500 dark:text-gray-400 mt-1">
class="w-full h-2 bg-surface2 dark:bg-surface2 rounded-lg appearance-none cursor-pointer accent-blue-600">
<div class="flex justify-between text-xs text-muted dark:text-muted mt-1">
<span>{{ bounds.nodeSizeMultiplier.min }}</span>
<span>{{ bounds.nodeSizeMultiplier.max }}</span>
</div>
@@ -62,9 +62,9 @@ import { GraphConfig, GRAPH_CONFIG_BOUNDS } from '../../graph-settings.types';
<!-- Link thickness slider -->
<div>
<label class="flex items-center justify-between text-sm text-gray-700 dark:text-gray-300 mb-2">
<label class="flex items-center justify-between text-sm text-main dark:text-main mb-2">
<span>Link thickness</span>
<span class="font-mono text-xs bg-gray-100 dark:bg-gray-800 px-2 py-0.5 rounded">{{ formatNumber(config().lineSizeMultiplier, 2) }}</span>
<span class="font-mono text-xs bg-surface1 dark:bg-card px-2 py-0.5 rounded">{{ formatNumber(config().lineSizeMultiplier, 2) }}</span>
</label>
<input
type="range"
@@ -73,8 +73,8 @@ import { GraphConfig, GRAPH_CONFIG_BOUNDS } from '../../graph-settings.types';
[step]="bounds.lineSizeMultiplier.step"
[value]="config().lineSizeMultiplier"
(input)="onSliderChange('lineSizeMultiplier', $event)"
class="w-full h-2 bg-gray-200 dark:bg-gray-700 rounded-lg appearance-none cursor-pointer accent-blue-600">
<div class="flex justify-between text-xs text-gray-500 dark:text-gray-400 mt-1">
class="w-full h-2 bg-surface2 dark:bg-surface2 rounded-lg appearance-none cursor-pointer accent-blue-600">
<div class="flex justify-between text-xs text-muted dark:text-muted mt-1">
<span>{{ bounds.lineSizeMultiplier.min }}</span>
<span>{{ bounds.lineSizeMultiplier.max }}</span>
</div>
@@ -31,8 +31,8 @@ import { GraphConfig } from '../../graph-settings.types';
type="checkbox"
[checked]="config().showTags"
(change)="onToggleChange('showTags', $event)"
class="w-4 h-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700 cursor-pointer">
<span class="text-sm text-gray-700 dark:text-gray-300 group-hover:text-gray-900 dark:group-hover:text-gray-100 select-none">Tags</span>
class="w-4 h-4 rounded border-border text-blue-600 focus:ring-ring dark:border-border dark:bg-surface2 cursor-pointer">
<span class="text-sm text-main dark:text-main group-hover:text-main dark:group-hover:text-gray-100 select-none">Tags</span>
</label>
<label class="flex items-center gap-2 cursor-pointer group">
@@ -40,8 +40,8 @@ import { GraphConfig } from '../../graph-settings.types';
type="checkbox"
[checked]="config().showAttachments"
(change)="onToggleChange('showAttachments', $event)"
class="w-4 h-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700 cursor-pointer">
<span class="text-sm text-gray-700 dark:text-gray-300 group-hover:text-gray-900 dark:group-hover:text-gray-100 select-none">Attachments</span>
class="w-4 h-4 rounded border-border text-blue-600 focus:ring-ring dark:border-border dark:bg-surface2 cursor-pointer">
<span class="text-sm text-main dark:text-main group-hover:text-main dark:group-hover:text-gray-100 select-none">Attachments</span>
</label>
<label class="flex items-center gap-2 cursor-pointer group">
@@ -49,8 +49,8 @@ import { GraphConfig } from '../../graph-settings.types';
type="checkbox"
[checked]="config().hideUnresolved"
(change)="onToggleChange('hideUnresolved', $event)"
class="w-4 h-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700 cursor-pointer">
<span class="text-sm text-gray-700 dark:text-gray-300 group-hover:text-gray-900 dark:group-hover:text-gray-100 select-none">Existing files only</span>
class="w-4 h-4 rounded border-border text-blue-600 focus:ring-ring dark:border-border dark:bg-surface2 cursor-pointer">
<span class="text-sm text-main dark:text-main group-hover:text-main dark:group-hover:text-gray-100 select-none">Existing files only</span>
</label>
<label class="flex items-center gap-2 cursor-pointer group">
@@ -58,8 +58,8 @@ import { GraphConfig } from '../../graph-settings.types';
type="checkbox"
[checked]="config().showOrphans"
(change)="onToggleChange('showOrphans', $event)"
class="w-4 h-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700 cursor-pointer">
<span class="text-sm text-gray-700 dark:text-gray-300 group-hover:text-gray-900 dark:group-hover:text-gray-100 select-none">Orphans</span>
class="w-4 h-4 rounded border-border text-blue-600 focus:ring-ring dark:border-border dark:bg-surface2 cursor-pointer">
<span class="text-sm text-main dark:text-main group-hover:text-main dark:group-hover:text-gray-100 select-none">Orphans</span>
</label>
</div>
</div>
@@ -12,9 +12,9 @@ import { GraphConfig, GRAPH_CONFIG_BOUNDS } from '../../graph-settings.types';
<div class="space-y-4">
<!-- Center force slider -->
<div>
<label class="flex items-center justify-between text-sm text-gray-700 dark:text-gray-300 mb-2">
<label class="flex items-center justify-between text-sm text-main dark:text-main mb-2">
<span>Center force</span>
<span class="font-mono text-xs bg-gray-100 dark:bg-gray-800 px-2 py-0.5 rounded">{{ formatNumber(config().centerStrength, 2) }}</span>
<span class="font-mono text-xs bg-surface1 dark:bg-card px-2 py-0.5 rounded">{{ formatNumber(config().centerStrength, 2) }}</span>
</label>
<input
type="range"
@@ -23,8 +23,8 @@ import { GraphConfig, GRAPH_CONFIG_BOUNDS } from '../../graph-settings.types';
[step]="bounds.centerStrength.step"
[value]="config().centerStrength"
(input)="onSliderChange('centerStrength', $event)"
class="w-full h-2 bg-gray-200 dark:bg-gray-700 rounded-lg appearance-none cursor-pointer accent-blue-600">
<div class="flex justify-between text-xs text-gray-500 dark:text-gray-400 mt-1">
class="w-full h-2 bg-surface2 dark:bg-surface2 rounded-lg appearance-none cursor-pointer accent-blue-600">
<div class="flex justify-between text-xs text-muted dark:text-muted mt-1">
<span>{{ bounds.centerStrength.min }}</span>
<span>{{ bounds.centerStrength.max }}</span>
</div>
@@ -32,9 +32,9 @@ import { GraphConfig, GRAPH_CONFIG_BOUNDS } from '../../graph-settings.types';
<!-- Repel force slider -->
<div>
<label class="flex items-center justify-between text-sm text-gray-700 dark:text-gray-300 mb-2">
<label class="flex items-center justify-between text-sm text-main dark:text-main mb-2">
<span>Repel force</span>
<span class="font-mono text-xs bg-gray-100 dark:bg-gray-800 px-2 py-0.5 rounded">{{ formatNumber(config().repelStrength, 1) }}</span>
<span class="font-mono text-xs bg-surface1 dark:bg-card px-2 py-0.5 rounded">{{ formatNumber(config().repelStrength, 1) }}</span>
</label>
<input
type="range"
@@ -43,8 +43,8 @@ import { GraphConfig, GRAPH_CONFIG_BOUNDS } from '../../graph-settings.types';
[step]="bounds.repelStrength.step"
[value]="config().repelStrength"
(input)="onSliderChange('repelStrength', $event)"
class="w-full h-2 bg-gray-200 dark:bg-gray-700 rounded-lg appearance-none cursor-pointer accent-blue-600">
<div class="flex justify-between text-xs text-gray-500 dark:text-gray-400 mt-1">
class="w-full h-2 bg-surface2 dark:bg-surface2 rounded-lg appearance-none cursor-pointer accent-blue-600">
<div class="flex justify-between text-xs text-muted dark:text-muted mt-1">
<span>{{ bounds.repelStrength.min }}</span>
<span>{{ bounds.repelStrength.max }}</span>
</div>
@@ -52,9 +52,9 @@ import { GraphConfig, GRAPH_CONFIG_BOUNDS } from '../../graph-settings.types';
<!-- Link force slider -->
<div>
<label class="flex items-center justify-between text-sm text-gray-700 dark:text-gray-300 mb-2">
<label class="flex items-center justify-between text-sm text-main dark:text-main mb-2">
<span>Link force</span>
<span class="font-mono text-xs bg-gray-100 dark:bg-gray-800 px-2 py-0.5 rounded">{{ formatNumber(config().linkStrength, 2) }}</span>
<span class="font-mono text-xs bg-surface1 dark:bg-card px-2 py-0.5 rounded">{{ formatNumber(config().linkStrength, 2) }}</span>
</label>
<input
type="range"
@@ -63,8 +63,8 @@ import { GraphConfig, GRAPH_CONFIG_BOUNDS } from '../../graph-settings.types';
[step]="bounds.linkStrength.step"
[value]="config().linkStrength"
(input)="onSliderChange('linkStrength', $event)"
class="w-full h-2 bg-gray-200 dark:bg-gray-700 rounded-lg appearance-none cursor-pointer accent-blue-600">
<div class="flex justify-between text-xs text-gray-500 dark:text-gray-400 mt-1">
class="w-full h-2 bg-surface2 dark:bg-surface2 rounded-lg appearance-none cursor-pointer accent-blue-600">
<div class="flex justify-between text-xs text-muted dark:text-muted mt-1">
<span>{{ bounds.linkStrength.min }}</span>
<span>{{ bounds.linkStrength.max }}</span>
</div>
@@ -72,9 +72,9 @@ import { GraphConfig, GRAPH_CONFIG_BOUNDS } from '../../graph-settings.types';
<!-- Link distance slider -->
<div>
<label class="flex items-center justify-between text-sm text-gray-700 dark:text-gray-300 mb-2">
<label class="flex items-center justify-between text-sm text-main dark:text-main mb-2">
<span>Link distance</span>
<span class="font-mono text-xs bg-gray-100 dark:bg-gray-800 px-2 py-0.5 rounded">{{ formatNumber(config().linkDistance, 0) }}px</span>
<span class="font-mono text-xs bg-surface1 dark:bg-card px-2 py-0.5 rounded">{{ formatNumber(config().linkDistance, 0) }}px</span>
</label>
<input
type="range"
@@ -83,8 +83,8 @@ import { GraphConfig, GRAPH_CONFIG_BOUNDS } from '../../graph-settings.types';
[step]="bounds.linkDistance.step"
[value]="config().linkDistance"
(input)="onSliderChange('linkDistance', $event)"
class="w-full h-2 bg-gray-200 dark:bg-gray-700 rounded-lg appearance-none cursor-pointer accent-blue-600">
<div class="flex justify-between text-xs text-gray-500 dark:text-gray-400 mt-1">
class="w-full h-2 bg-surface2 dark:bg-surface2 rounded-lg appearance-none cursor-pointer accent-blue-600">
<div class="flex justify-between text-xs text-muted dark:text-muted mt-1">
<span>{{ bounds.linkDistance.min }}</span>
<span>{{ bounds.linkDistance.max }}</span>
</div>
@@ -14,7 +14,7 @@ import { GraphConfig, GraphColorGroup, intToHex, createGraphColor } from '../../
@if (config().colorGroups.length > 0) {
<div class="space-y-2">
@for (group of config().colorGroups; track $index) {
<div class="flex items-center gap-2 p-2 rounded-lg bg-gray-50 dark:bg-gray-800 border border-gray-200 dark:border-gray-700">
<div class="flex items-center gap-2 p-2 rounded-lg bg-surface1 dark:bg-card border border-border dark:border-border">
<!-- Color picker -->
<input
type="color"
@@ -29,7 +29,7 @@ import { GraphConfig, GraphColorGroup, intToHex, createGraphColor } from '../../
[value]="group.query"
(input)="onQueryChange($index, $event)"
placeholder="tag:#example"
class="flex-1 px-2 py-1 text-sm border border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 placeholder-gray-500 dark:placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-500">
class="flex-1 px-2 py-1 text-sm border border-border dark:border-border rounded bg-card dark:bg-surface2 text-main dark:text-gray-100 placeholder-gray-500 dark:placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-500">
<!-- Actions -->
<button
@@ -65,11 +65,11 @@ import { GraphConfig, GraphColorGroup, intToHex, createGraphColor } from '../../
</button>
<!-- Help text -->
<div class="text-xs text-gray-500 dark:text-gray-400 space-y-1">
<div class="text-xs text-muted dark:text-muted space-y-1">
<div><strong>Examples:</strong></div>
<div>• <code class="px-1 py-0.5 bg-gray-100 dark:bg-gray-800 rounded">tag:#markdown</code></div>
<div>• <code class="px-1 py-0.5 bg-gray-100 dark:bg-gray-800 rounded">file:test</code></div>
<div>• <code class="px-1 py-0.5 bg-gray-100 dark:bg-gray-800 rounded">path:folder</code></div>
<div>• <code class="px-1 py-0.5 bg-surface1 dark:bg-card rounded">tag:#markdown</code></div>
<div>• <code class="px-1 py-0.5 bg-surface1 dark:bg-card rounded">file:test</code></div>
<div>• <code class="px-1 py-0.5 bg-surface1 dark:bg-card rounded">path:folder</code></div>
</div>
</div>
`,
+1 -1
View File
@@ -19,7 +19,7 @@ import { GraphSettingsAccordionComponent } from '../../../components/graph-setti
<div class="panel-content">
<div class="panel-header">
<h2 class="text-lg font-semibold text-gray-900 dark:text-gray-100">Graph settings</h2>
<h2 class="text-lg font-semibold text-main dark:text-gray-100">Graph settings</h2>
<div class="flex items-center gap-2">
<!-- Expand all -->
<button
@@ -16,16 +16,17 @@ import { NimbusSidebarComponent } from '../../features/sidebar/nimbus-sidebar.co
import { QuickLinksComponent } from '../../features/quick-links/quick-links.component';
import { ScrollableOverlayDirective } from '../../shared/overlay-scrollbar/scrollable-overlay.directive';
import { MarkdownPlaygroundComponent } from '../../features/tests/markdown-playground/markdown-playground.component';
import { ParametersPage } from '../../features/parameters/parameters.page';
@Component({
selector: 'app-shell-nimbus-layout',
standalone: true,
imports: [CommonModule, FileExplorerComponent, NoteViewerComponent, AppBottomNavigationComponent, AppSidebarDrawerComponent, AppTocOverlayComponent, SwipeNavDirective, NotesListComponent, NimbusSidebarComponent, QuickLinksComponent, ScrollableOverlayDirective, MarkdownPlaygroundComponent],
imports: [CommonModule, FileExplorerComponent, NoteViewerComponent, AppBottomNavigationComponent, AppSidebarDrawerComponent, AppTocOverlayComponent, SwipeNavDirective, NotesListComponent, NimbusSidebarComponent, QuickLinksComponent, ScrollableOverlayDirective, MarkdownPlaygroundComponent, ParametersPage],
template: `
<div class="relative h-screen flex flex-col bg-white dark:bg-gray-900 text-gray-900 dark:text-gray-100">
<div class="relative h-screen flex flex-col bg-card dark:bg-main text-main dark:text-gray-100">
<!-- Fullscreen overlay for note -->
<div *ngIf="noteFullScreen && selectedNote && activeView !== 'markdown-playground'" class="absolute inset-0 z-50 flex flex-col bg-white dark:bg-gray-900">
<div *ngIf="noteFullScreen && selectedNote && activeView !== 'markdown-playground'" class="absolute inset-0 z-50 flex flex-col bg-card dark:bg-main">
<div class="note-content-area flex-1 overflow-y-auto px-4 py-4 lg:px-12" appScrollableOverlay>
<app-note-viewer
[note]="selectedNote || null"
@@ -37,6 +38,7 @@ import { MarkdownPlaygroundComponent } from '../../features/tests/markdown-playg
[fullScreenActive]="noteFullScreen"
(fullScreenRequested)="toggleNoteFullScreen()"
(legacyRequested)="ui.toggleUIMode()"
(parametersRequested)="onParametersOpen()"
(showToc)="toggleOutlineRequest.emit()"
(directoryClicked)="onFolderSelected($event)"
[tocOpen]="isOutlineOpen"
@@ -48,7 +50,7 @@ import { MarkdownPlaygroundComponent } from '../../features/tests/markdown-playg
<div *ngIf="responsive.isDesktop() && !noteFullScreen" class="flex-1 flex overflow-hidden relative">
<!-- Left: full sidebar or collapsed rail -->
<ng-container *ngIf="isSidebarOpen; else collapsedRail">
<aside class="flex flex-col border-r border-gray-200 dark:border-gray-800 min-h-0" [style.width.px]="leftSidebarWidth">
<aside class="flex flex-col border-r border-border dark:border-gray-800 min-h-0" [style.width.px]="leftSidebarWidth">
<app-nimbus-sidebar
[vaultName]="vaultName"
[effectiveFileTree]="effectiveFileTree"
@@ -64,19 +66,19 @@ import { MarkdownPlaygroundComponent } from '../../features/tests/markdown-playg
</aside>
</ng-container>
<ng-template #collapsedRail>
<aside class="border-r border-gray-200 dark:border-gray-800 h-full w-14 flex flex-col items-center py-3 gap-3">
<button class="p-2 rounded hover:bg-gray-100 dark:hover:bg-gray-800" (click)="toggleSidebarRequest.emit()" title="Show Sidebar">☰</button>
<button class="p-2 rounded hover:bg-gray-100 dark:hover:bg-gray-800" (mouseenter)="openFlyout('quick')" (mouseleave)="scheduleCloseFlyout()" title="Quick Links">▦</button>
<button class="p-2 rounded hover:bg-gray-100 dark:hover:bg-gray-800" (mouseenter)="openFlyout('folders')" (mouseleave)="scheduleCloseFlyout()" title="Folders">📁</button>
<button class="p-2 rounded hover:bg-gray-100 dark:hover:bg-gray-800" (mouseenter)="openFlyout('tags')" (mouseleave)="scheduleCloseFlyout()" title="Tags">🏷️</button>
<button class="p-2 rounded hover:bg-gray-100 dark:hover:bg-gray-800" (mouseenter)="openFlyout('trash')" (mouseleave)="scheduleCloseFlyout()" title="Trash">🗑️</button>
<aside class="border-r border-border dark:border-gray-800 h-full w-14 flex flex-col items-center py-3 gap-3">
<button class="p-2 rounded hover:bg-surface1 dark:hover:bg-card" (click)="toggleSidebarRequest.emit()" title="Show Sidebar">☰</button>
<button class="p-2 rounded hover:bg-surface1 dark:hover:bg-card" (mouseenter)="openFlyout('quick')" (mouseleave)="scheduleCloseFlyout()" title="Quick Links">▦</button>
<button class="p-2 rounded hover:bg-surface1 dark:hover:bg-card" (mouseenter)="openFlyout('folders')" (mouseleave)="scheduleCloseFlyout()" title="Folders">📁</button>
<button class="p-2 rounded hover:bg-surface1 dark:hover:bg-card" (mouseenter)="openFlyout('tags')" (mouseleave)="scheduleCloseFlyout()" title="Tags">🏷️</button>
<button class="p-2 rounded hover:bg-surface1 dark:hover:bg-card" (mouseenter)="openFlyout('trash')" (mouseleave)="scheduleCloseFlyout()" title="Trash">🗑️</button>
</aside>
<!-- Flyouts -->
<div class="absolute left-14 top-0 bottom-0 w-80 max-w-[70vw] bg-white dark:bg-gray-900 border-r border-gray-200 dark:border-gray-800 shadow-xl z-50" *ngIf="hoveredFlyout as f" (mouseenter)="cancelCloseFlyout()" (mouseleave)="scheduleCloseFlyout()">
<div class="h-12 flex items-center justify-between px-3 border-b border-gray-200 dark:border-gray-800">
<div class="absolute left-14 top-0 bottom-0 w-80 max-w-[70vw] bg-card dark:bg-main border-r border-border dark:border-gray-800 shadow-xl z-50" *ngIf="hoveredFlyout as f" (mouseenter)="cancelCloseFlyout()" (mouseleave)="scheduleCloseFlyout()">
<div class="h-12 flex items-center justify-between px-3 border-b border-border dark:border-gray-800">
<div class="text-sm font-semibold">{{ f === 'quick' ? 'Quick Links' : (f === 'folders' ? 'Folders' : (f === 'tags' ? 'Tags' : 'Trash')) }}</div>
<button class="p-2 rounded hover:bg-gray-100 dark:hover:bg-gray-800" (click)="hoveredFlyout=null">✕</button>
<button class="p-2 rounded hover:bg-surface1 dark:hover:bg-card" (click)="hoveredFlyout=null">✕</button>
</div>
<div class="h-[calc(100%-3rem)] overflow-y-auto" appScrollableOverlay>
<ng-container [ngSwitch]="f">
@@ -87,21 +89,21 @@ import { MarkdownPlaygroundComponent } from '../../features/tests/markdown-playg
<div *ngSwitchCase="'tags'" class="p-2">
<ul class="space-y-0.5 text-sm">
<li *ngFor="let t of tags">
<button (click)="onTagSelected(t.name)" class="w-full text-left px-2 py-1 rounded hover:bg-gray-100 dark:hover:bg-gray-800 truncate">🏷️ {{ t.name }} <span class="text-xs text-gray-500">{{ t.count }}</span></button>
<button (click)="onTagSelected(t.name)" class="w-full text-left px-2 py-1 rounded hover:bg-surface1 dark:hover:bg-card truncate">🏷️ {{ t.name }} <span class="text-xs text-muted">{{ t.count }}</span></button>
</li>
</ul>
</div>
<div *ngSwitchDefault class="p-3 text-sm text-gray-500">Empty</div>
<div *ngSwitchDefault class="p-3 text-sm text-muted">Empty</div>
</ng-container>
</div>
</div>
</ng-template>
<!-- Left Resizer -->
<div class="h-full w-1 cursor-col-resize hover:bg-gray-200/50 dark:hover:bg-gray-700/50" (pointerdown)="leftResizeStart.emit($event)" role="separator" aria-orientation="vertical" aria-label="Redimensionner la barre latérale gauche"></div>
<div class="h-full w-1 cursor-col-resize hover:bg-surface2/50 dark:hover:bg-surface2/50" (pointerdown)="leftResizeStart.emit($event)" role="separator" aria-orientation="vertical" aria-label="Redimensionner la barre latérale gauche"></div>
<!-- Center List -->
<section class="border-r border-gray-200 dark:border-gray-800 overflow-hidden" [style.width.px]="centerPanelWidth">
<section class="border-r border-border dark:border-gray-800 overflow-hidden" [style.width.px]="centerPanelWidth">
<div class="h-full flex flex-col">
<app-notes-list class="flex-1"
[notes]="vault.allNotes()"
@@ -116,13 +118,14 @@ import { MarkdownPlaygroundComponent } from '../../features/tests/markdown-playg
</section>
<!-- Center Resizer (between list and note) -->
<div class="h-full w-1 cursor-col-resize hover:bg-gray-200/50 dark:hover:bg-gray-700/50" (pointerdown)="centerResizeStart.emit($event)" role="separator" aria-orientation="vertical" aria-label="Redimensionner la zone de liste"></div>
<div class="h-full w-1 cursor-col-resize hover:bg-surface2/50 dark:hover:bg-surface2/50" (pointerdown)="centerResizeStart.emit($event)" role="separator" aria-orientation="vertical" aria-label="Redimensionner la zone de liste"></div>
<!-- Note View + ToC -->
<section class="flex-1 relative min-w-0 flex">
<div class="note-content-area flex-1 overflow-y-auto px-4 py-4 lg:px-8" appScrollableOverlay>
<app-markdown-playground *ngIf="activeView === 'markdown-playground'"></app-markdown-playground>
<app-note-viewer *ngIf="activeView !== 'markdown-playground'"
<app-parameters *ngIf="activeView === 'parameters'"></app-parameters>
<app-note-viewer *ngIf="activeView !== 'markdown-playground' && activeView !== 'parameters'"
[note]="selectedNote || null"
[noteHtmlContent]="renderedNoteContent"
[allNotes]="vault.allNotes()"
@@ -132,17 +135,18 @@ import { MarkdownPlaygroundComponent } from '../../features/tests/markdown-playg
[fullScreenActive]="noteFullScreen"
(fullScreenRequested)="toggleNoteFullScreen()"
(legacyRequested)="ui.toggleUIMode()"
(parametersRequested)="onParametersOpen()"
(showToc)="toggleOutlineRequest.emit()"
(directoryClicked)="onFolderSelected($event)"
[tocOpen]="isOutlineOpen"
></app-note-viewer>
</div>
<aside class="hidden xl:block border-l border-gray-200 dark:border-gray-800 overflow-y-auto transition-all duration-300 ease-in-out" appScrollableOverlay [style.width.px]="isOutlineOpen ? rightSidebarWidth : 0" [class.opacity-0]="!isOutlineOpen" [class.pointer-events-none]="!isOutlineOpen">
<aside class="hidden xl:block border-l border-border dark:border-gray-800 overflow-y-auto transition-all duration-300 ease-in-out" appScrollableOverlay [style.width.px]="isOutlineOpen ? rightSidebarWidth : 0" [class.opacity-0]="!isOutlineOpen" [class.pointer-events-none]="!isOutlineOpen">
<div class="p-3">
<h2 class="text-sm font-semibold mb-2">Sommaire</h2>
<ul class="space-y-1 text-sm text-gray-600 dark:text-gray-300">
<ul class="space-y-1 text-sm text-muted dark:text-main">
<li *ngFor="let h of tableOfContents">
<a class="block truncate hover:text-gray-900 dark:hover:text-white cursor-pointer" (click)="navigateHeading.emit(h.id)" [style.paddingLeft.rem]="(h.level - 1) * 0.75">{{ h.text }}</a>
<a class="block truncate hover:text-main dark:hover:text-white cursor-pointer" (click)="navigateHeading.emit(h.id)" [style.paddingLeft.rem]="(h.level - 1) * 0.75">{{ h.text }}</a>
</li>
</ul>
</div>
@@ -152,7 +156,7 @@ import { MarkdownPlaygroundComponent } from '../../features/tests/markdown-playg
<!-- Tablet: simple tabbed areas -->
<div *ngIf="responsive.isTablet() && !noteFullScreen" class="flex-1 flex flex-col overflow-hidden">
<div class="h-12 border-b border-gray-200 dark:border-gray-800 flex items-center">
<div class="h-12 border-b border-border dark:border-gray-800 flex items-center">
<button class="flex-1 h-full text-sm" [class.text-nimbus-500]="mobileNav.activeTab() === 'sidebar'" (click)="mobileNav.setActiveTab('sidebar')">Sidebar</button>
<button class="flex-1 h-full text-sm" [class.text-nimbus-500]="mobileNav.activeTab() === 'list'" (click)="mobileNav.setActiveTab('list')">Liste</button>
<button class="flex-1 h-full text-sm" [class.text-nimbus-500]="mobileNav.activeTab() === 'page'" (click)="mobileNav.setActiveTab('page')">Page</button>
@@ -165,7 +169,9 @@ import { MarkdownPlaygroundComponent } from '../../features/tests/markdown-playg
<app-notes-list [notes]="vault.allNotes()" [folderFilter]="folderFilter" [tagFilter]="tagFilter" [quickLinkFilter]="quickLinkFilter" [query]="listQuery" (queryChange)="listQuery=$event" (openNote)="onOpenNote($event)"></app-notes-list>
</div>
<div [hidden]="mobileNav.activeTab() !== 'page'" class="note-content-area h-full overflow-y-auto px-3 py-4" appScrollableOverlay>
<app-note-viewer [note]="selectedNote || null" [noteHtmlContent]="renderedNoteContent" [allNotes]="vault.allNotes()" (noteLinkClicked)="noteSelected.emit($event)" (tagClicked)="onTagSelected($event)" (wikiLinkActivated)="wikiLinkActivated.emit($event)" [fullScreenActive]="noteFullScreen" (fullScreenRequested)="toggleNoteFullScreen()" (legacyRequested)="ui.toggleUIMode()" (showToc)="mobileNav.toggleToc()" (directoryClicked)="onFolderSelected($event)" [tocOpen]="mobileNav.tocOpen()"></app-note-viewer>
<app-markdown-playground *ngIf="activeView === 'markdown-playground'"></app-markdown-playground>
<app-parameters *ngIf="activeView === 'parameters'"></app-parameters>
<app-note-viewer *ngIf="activeView !== 'markdown-playground' && activeView !== 'parameters'" [note]="selectedNote || null" [noteHtmlContent]="renderedNoteContent" [allNotes]="vault.allNotes()" (noteLinkClicked)="noteSelected.emit($event)" (tagClicked)="onTagSelected($event)" (wikiLinkActivated)="wikiLinkActivated.emit($event)" [fullScreenActive]="noteFullScreen" (fullScreenRequested)="toggleNoteFullScreen()" (legacyRequested)="ui.toggleUIMode()" (parametersRequested)="onParametersOpen()" (showToc)="mobileNav.toggleToc()" (directoryClicked)="onFolderSelected($event)" [tocOpen]="mobileNav.tocOpen()"></app-note-viewer>
</div>
</div>
</div>
@@ -191,26 +197,36 @@ import { MarkdownPlaygroundComponent } from '../../features/tests/markdown-playg
}
@if (mobileNav.activeTab() === 'page') {
<div class="note-content-area h-full px-3 py-3 animate-fadeIn" style="overflow-y: auto !important;" appScrollableOverlay>
<div class="flex items-center justify-between mb-3 sticky top-0 bg-white dark:bg-gray-900 py-2 -mt-2 z-10">
<h2 class="text-base font-semibold truncate">{{ selectedNote?.title || 'Aucune page' }}</h2>
<button
*ngIf="tableOfContents.length > 0"
(pointerdown)="$event.stopPropagation(); mobileNav.toggleToc()"
(click)="$event.preventDefault()"
class="p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-800 active:bg-gray-200 dark:active:bg-gray-700 transition-all active:scale-95 transform flex-shrink-0">
📋
</button>
@if (activeView === 'parameters') {
<div class="note-content-area h-full px-3 py-3 animate-fadeIn" style="overflow-y: auto !important;" appScrollableOverlay>
<app-parameters></app-parameters>
</div>
@if (selectedNote) {
<app-note-viewer [note]="selectedNote || null" [noteHtmlContent]="renderedNoteContent" [allNotes]="vault.allNotes()" (noteLinkClicked)="noteSelected.emit($event)" (tagClicked)="tagClicked.emit($event)" (wikiLinkActivated)="wikiLinkActivated.emit($event)" (fullScreenRequested)="toggleNoteFullScreen()"></app-note-viewer>
} @else {
<div class="mt-10 text-center text-sm text-gray-500 dark:text-gray-400">
<div class="text-4xl mb-3">📄</div>
<p>Aucune page sélectionnée pour le moment.</p>
} @else if (activeView === 'markdown-playground') {
<div class="note-content-area h-full px-3 py-3 animate-fadeIn" style="overflow-y: auto !important;" appScrollableOverlay>
<app-markdown-playground></app-markdown-playground>
</div>
} @else {
<div class="note-content-area h-full px-3 py-3 animate-fadeIn" style="overflow-y: auto !important;" appScrollableOverlay>
<div class="flex items-center justify-between mb-3 sticky top-0 bg-card dark:bg-main py-2 -mt-2 z-10">
<h2 class="text-base font-semibold truncate">{{ selectedNote?.title || 'Aucune page' }}</h2>
<button
*ngIf="tableOfContents.length > 0"
(pointerdown)="$event.stopPropagation(); mobileNav.toggleToc()"
(click)="$event.preventDefault()"
class="p-2 rounded-lg hover:bg-surface1 dark:hover:bg-card active:bg-surface2 dark:active:bg-gray-700 transition-all active:scale-95 transform flex-shrink-0">
📋
</button>
</div>
}
</div>
@if (selectedNote) {
<app-note-viewer [note]="selectedNote || null" [noteHtmlContent]="renderedNoteContent" [allNotes]="vault.allNotes()" (noteLinkClicked)="noteSelected.emit($event)" (tagClicked)="tagClicked.emit($event)" (wikiLinkActivated)="wikiLinkActivated.emit($event)" (fullScreenRequested)="toggleNoteFullScreen()" (parametersRequested)="onParametersOpen()"></app-note-viewer>
} @else {
<div class="mt-10 text-center text-sm text-muted dark:text-muted">
<div class="text-4xl mb-3">📄</div>
<p>Aucune page sélectionnée pour le moment.</p>
</div>
}
</div>
}
}
<app-toc-overlay *ngIf="mobileNav.tocOpen()" [headings]="tableOfContents" (go)="onTocNavigate($event)" (close)="mobileNav.toggleToc()"></app-toc-overlay>
@@ -255,6 +271,7 @@ export class AppShellNimbusLayoutComponent {
@Output() searchTermChange = new EventEmitter<string>();
@Output() searchOptionsChange = new EventEmitter<any>();
@Output() markdownPlaygroundSelected = new EventEmitter<void>();
@Output() parametersOpened = new EventEmitter<void>();
folderFilter: string | null = null;
listQuery: string = '';
@@ -443,6 +460,10 @@ export class AppShellNimbusLayoutComponent {
this.markdownPlaygroundSelected.emit();
}
onParametersOpen(): void {
this.parametersOpened.emit();
}
onTocNavigate(headingId: string): void {
// Ensure the page view is visible so the scroll container exists
this.mobileNav.setActiveTab('page');
@@ -15,7 +15,7 @@ import { BadgeCountComponent } from '../../../../app/shared/ui/badge-count.compo
<div>
<div
(click)="onFolderClick(folder)"
class="flex items-center cursor-pointer px-3 py-2 rounded-lg my-0.5 text-sm hover:bg-slate-500/10 dark:hover:bg-slate-200/10 transition"
class="flex items-center cursor-pointer px-3 py-2 rounded-lg my-0.5 text-sm hover:bg-slate-500/10 dark:hover:bg-surface2/10 transition"
>
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 mr-2 transition-transform text-obs-l-text-muted dark:text-obs-d-text-muted" [class.rotate-90]="folder.isOpen" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
@@ -45,7 +45,7 @@ import { BadgeCountComponent } from '../../../../app/shared/ui/badge-count.compo
[class.bg-obs-l-bg-main]="selectedNoteId() === file.id"
[class.dark:bg-obs-d-bg-main]="selectedNoteId() === file.id"
[class.hover:bg-slate-500/10]="selectedNoteId() !== file.id"
[class.dark:hover:bg-slate-200/10]="selectedNoteId() !== file.id"
[class.dark:hover:bg-surface2/10]="selectedNoteId() !== file.id"
>
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 mr-2 flex-shrink-0 text-obs-l-text-muted dark:text-obs-d-text-muted" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
@@ -0,0 +1,31 @@
import { Injectable } from '@angular/core';
import { EditorView } from '@codemirror/view';
import { setHighlights, clearHighlights, HighlightRange } from './extensions/ranged-highlights.extension';
import { highlightOccurrences } from './extensions/highlight-occurrences.extension';
import { markdownThemeHighlightExt, highlightColorFacet } from './extensions/markdown-theme-highlight.extension';
import type { Extension, Compartment } from '@codemirror/state';
@Injectable({ providedIn: 'root' })
export class EditorHighlightService {
get extensions(): Extension[] {
return [markdownThemeHighlightExt()];
}
occurrencesExtension(pattern: string | RegExp): Extension {
return highlightOccurrences(pattern);
}
applyOccurrences(view: EditorView, compartment: Compartment, pattern: string | RegExp): void {
view.dispatch({ effects: compartment.reconfigure(this.occurrencesExtension(pattern)) });
}
setRanges(view: EditorView, ranges: HighlightRange[]): void {
view.dispatch({ effects: setHighlights.of(ranges) });
}
clearRanges(view: EditorView): void {
view.dispatch({ effects: clearHighlights.of(null) });
}
facet() { return highlightColorFacet; }
}
@@ -0,0 +1,49 @@
import type { Extension } from '@codemirror/state';
import { Decoration, DecorationSet, EditorView, ViewPlugin, ViewUpdate } from '@codemirror/view';
export interface OccurrenceOptions {
pattern: string | RegExp;
}
function buildRegex(pattern: string | RegExp): RegExp {
if (pattern instanceof RegExp) return pattern;
// default: case-insensitive word-like TODO/FIXME etc. if not a regex
const source = pattern.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
return new RegExp(source, 'gi');
}
function decorateDoc(view: EditorView, re: RegExp): DecorationSet {
const builder: any[] = [];
const deco = Decoration.mark({ class: 'cm-md-highlight' });
for (let { from, to } of view.visibleRanges) {
const text = view.state.doc.sliceString(from, to);
re.lastIndex = 0;
let m: RegExpExecArray | null;
while ((m = re.exec(text))) {
const start = from + m.index;
const end = start + m[0].length;
builder.push(deco.range(start, end));
// avoid infinite loops on zero-length matches
if (m[0].length === 0) re.lastIndex++;
}
}
return Decoration.set(builder, true);
}
export function highlightOccurrences(pattern: string | RegExp): Extension {
const re = buildRegex(pattern);
const plugin = ViewPlugin.fromClass(class {
decorations: DecorationSet;
constructor(view: EditorView) {
this.decorations = decorateDoc(view, re);
}
update(update: ViewUpdate) {
if (update.docChanged || update.viewportChanged) {
this.decorations = decorateDoc(update.view, re);
}
}
}, { decorations: v => v.decorations });
return [plugin];
}
@@ -0,0 +1,148 @@
import { Facet, Extension, RangeSetBuilder } from '@codemirror/state';
import { Decoration, DecorationSet, EditorView, ViewPlugin, ViewUpdate } from '@codemirror/view';
import { HighlightStyle, syntaxHighlighting, syntaxTree } from '@codemirror/language';
import { tags as t } from '@lezer/highlight';
import { rangedHighlightsExt } from './ranged-highlights.extension';
export const highlightColorFacet = Facet.define<string | null, string | null>({
combine: values => values.length ? values[values.length - 1] : null
});
const markdownBaseTheme = EditorView.baseTheme({
'.cm-inline-code': {
backgroundColor: 'var(--md-code-bg)',
color: 'var(--md-code-fg)',
borderRadius: 'var(--cm-hl-br, 3px)',
padding: '0 0.3rem',
fontFamily: 'var(--code-font, "JetBrains Mono", "Fira Code", monospace)',
},
'.cm-codeblock': {
backgroundColor: 'var(--md-pre-bg)',
color: 'var(--md-pre-fg)',
borderRadius: 'var(--cm-hl-br, 3px)',
padding: '0.4rem 0.6rem',
display: 'block',
},
'.cm-codeblock + .cm-codeblock': {
marginTop: '0.2rem',
},
'.cm-codeblock .cm-highlight': {
background: 'transparent !important',
},
'.cm-codeinfo': {
color: 'var(--md-muted)',
fontFamily: 'var(--code-font, "JetBrains Mono", "Fira Code", monospace)',
fontSize: '0.75rem',
textTransform: 'lowercase',
letterSpacing: '0.02em'
}
});
const codeBlockLineDeco = Decoration.line({ class: 'cm-codeblock' });
const codeInfoDeco = Decoration.mark({ class: 'cm-codeinfo' });
function codeBlockDecorations(view: EditorView): DecorationSet {
const builder = new RangeSetBuilder<Decoration>();
const tree = syntaxTree(view.state);
for (const range of view.visibleRanges) {
tree.iterate({
from: range.from,
to: range.to,
enter: node => {
const name = node.type.name;
if (name === 'FencedCode' || name === 'CodeBlock') {
let line = view.state.doc.lineAt(node.from);
const maxLine = view.state.doc.lineAt(node.to).number;
while (line.number <= maxLine) {
builder.add(line.from, line.from, codeBlockLineDeco);
if (line.number === maxLine) break;
line = view.state.doc.line(line.number + 1);
}
return false;
}
if (name === 'CodeInfo') {
builder.add(node.from, node.to, codeInfoDeco);
}
return undefined;
}
});
}
return builder.finish();
}
const codeBlockPlugin = ViewPlugin.fromClass(class {
decorations: DecorationSet;
constructor(view: EditorView) {
this.decorations = codeBlockDecorations(view);
}
update(update: ViewUpdate) {
if (update.docChanged || update.viewportChanged) {
this.decorations = codeBlockDecorations(update.view);
}
}
}, {
decorations: v => v.decorations
});
function createFacetApplier(): Extension {
return ViewPlugin.fromClass(class {
constructor(private view: EditorView) {
this.apply();
}
update(update: ViewUpdate) {
if (update.docChanged || update.viewportChanged || update.transactions.some(tr => tr.effects.length)) {
this.apply(update.view);
}
}
private apply(view: EditorView = this.view) {
const value = view.state.facet(highlightColorFacet);
const root = view.dom as HTMLElement;
if (value) {
root.style.setProperty('--cm-hl-bg', value);
} else {
root.style.removeProperty('--cm-hl-bg');
}
}
});
}
const markdownThemeTokens = HighlightStyle.define([
{ tag: t.heading1, color: 'var(--md-h1)', fontWeight: '700' },
{ tag: t.heading2, color: 'var(--md-h2)', fontWeight: '700' },
{ tag: t.heading3, color: 'var(--md-h3)', fontWeight: '600' },
{ tag: t.heading4, color: 'var(--md-h4)', fontWeight: '600' },
{ tag: t.heading5, color: 'var(--md-h5)', fontWeight: '600' },
{ tag: t.heading6, color: 'var(--md-h6)', fontWeight: '600' },
{ tag: t.heading, color: 'var(--md-heading-accent, var(--md-h2))', fontWeight: '700' },
{ tag: t.emphasis, color: 'var(--md-text)', fontStyle: 'italic' },
{ tag: t.strong, color: 'var(--md-text)', fontWeight: '700' },
{ tag: t.strikethrough, color: 'var(--md-text)', textDecoration: 'line-through' },
{ tag: t.comment, color: 'var(--md-muted)', fontStyle: 'italic' },
{ tag: t.meta, color: 'var(--md-muted)' },
{ tag: t.quote, color: 'var(--md-quote-fg)', fontStyle: 'italic' },
{ tag: t.list, color: 'var(--md-text)' },
{ tag: [t.keyword], color: 'var(--md-syntax-1)' },
{ tag: [t.typeName, t.className], color: 'var(--md-syntax-2)' },
{ tag: t.string, color: 'var(--md-syntax-3)' },
{ tag: [t.number, t.bool], color: 'var(--md-syntax-4)' },
{ tag: [t.punctuation, t.operator], color: 'var(--md-syntax-5)' },
{ tag: [t.link, t.url, t.escape], color: 'var(--md-link)', textDecoration: 'underline' },
{ tag: t.monospace, class: 'cm-inline-code' },
{ tag: t.literal, color: 'var(--md-text)' },
]);
export function markdownThemeHighlightExt(): Extension {
return [
rangedHighlightsExt(),
syntaxHighlighting(markdownThemeTokens),
markdownBaseTheme,
codeBlockPlugin,
createFacetApplier(),
];
}
@@ -0,0 +1,41 @@
import { Extension, Range, StateEffect, StateField, Transaction } from '@codemirror/state';
import { Decoration, DecorationSet, EditorView } from '@codemirror/view';
export interface HighlightRange { from: number; to: number }
export const setHighlights = StateEffect.define<HighlightRange[]>();
export const clearHighlights = StateEffect.define<null>();
function mapRanges(ranges: HighlightRange[], tr: Transaction): HighlightRange[] {
return ranges.map(r => ({ from: tr.changes.mapPos(r.from, 1), to: tr.changes.mapPos(r.to, 1) }));
}
function buildDecos(ranges: HighlightRange[]): DecorationSet {
const deco = Decoration.mark({ class: 'cm-md-highlight' });
const specs: Range<Decoration>[] = [] as any;
for (const r of ranges) {
if (r.to > r.from) specs.push(deco.range(r.from, r.to));
}
return Decoration.set(specs, true);
}
export const rangedHighlightsField = StateField.define<DecorationSet>({
create() { return Decoration.none; },
update(decos, tr) {
decos = decos.map(tr.changes);
for (const e of tr.effects) {
if (e.is(setHighlights)) {
const mapped = mapRanges(e.value, tr);
decos = buildDecos(mapped);
} else if (e.is(clearHighlights)) {
decos = Decoration.none;
}
}
return decos;
},
provide: f => EditorView.decorations.from(f)
});
export function rangedHighlightsExt(): Extension {
return [rangedHighlightsField];
}
@@ -7,14 +7,14 @@
<ng-container *ngIf="currentTags().length; else emptyTpl">
<div class="flex flex-wrap gap-1.5 items-center">
<ng-container *ngFor="let t of currentTags()">
<span class="inline-flex items-center gap-1 rounded-xl bg-slate-100 dark:bg-slate-800 px-2 py-1 text-xs font-medium text-slate-700 dark:text-slate-300 border border-slate-200 dark:border-slate-700 shadow-sm transition-all duration-150 hover:bg-slate-200 dark:hover:bg-slate-700">
<span class="inline-flex items-center gap-1 rounded-xl bg-surface1 dark:bg-card px-2 py-1 text-xs font-medium text-main dark:text-main border border-border dark:border-border shadow-sm transition-all duration-150 hover:bg-surface2 dark:hover:bg-surface2">
{{ t }}
</span>
</ng-container>
</div>
</ng-container>
<ng-template #emptyTpl>
<span class="text-slate-500 dark:text-slate-400 text-xs italic">Cliquer pour ajouter des tags</span>
<span class="text-muted dark:text-muted text-xs italic">Cliquer pour ajouter des tags</span>
</ng-template>
</div>
</div>
@@ -22,21 +22,21 @@
<ng-template #editTpl>
<div class="relative w-full animate-in fade-in duration-200">
<!-- Carte d'édition avec backdrop blur -->
<div class="rounded-2xl p-3 sm:p-4 shadow-md border border-slate-200 dark:border-slate-700 bg-white/60 dark:bg-slate-900/50 backdrop-blur">
<div class="rounded-2xl p-3 sm:p-4 shadow-md border border-border dark:border-border bg-card/60 dark:bg-main/50 backdrop-blur">
<div class="flex items-start gap-2">
<!-- Zone des chips + input -->
<div class="flex-1 flex flex-wrap items-center gap-1.5 rounded-xl border border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-800 px-3 py-2 focus-within:ring-2 focus-within:ring-sky-400 dark:focus-within:ring-sky-500 transition-all duration-200">
<div class="flex-1 flex flex-wrap items-center gap-1.5 rounded-xl border border-border dark:border-border bg-card dark:bg-card px-3 py-2 focus-within:ring-2 focus-within:ring-ring transition-all duration-200">
<ng-container *ngFor="let tagState of workingTags()">
<span *ngIf="tagState.status !== 'removed'"
class="inline-flex items-center gap-1 rounded-xl px-2 py-1 text-xs font-medium border shadow-sm transition-all duration-150"
[ngClass]="{
'border-emerald-300/70 bg-emerald-50 dark:bg-emerald-900/20 text-emerald-700 dark:text-emerald-300': tagState.status === 'added',
'border-slate-300 dark:border-slate-600 bg-slate-50 dark:bg-slate-700 text-slate-700 dark:text-slate-300': tagState.status === 'unchanged'
'border-border dark:border-border bg-surface1 dark:bg-surface2 text-main dark:text-main': tagState.status === 'unchanged'
}">
<span *ngIf="tagState.status === 'added'" class="text-emerald-600 dark:text-emerald-400 font-bold">+</span>
{{ tagState.value }}
<button type="button"
class="ml-0.5 opacity-60 hover:opacity-100 hover:text-rose-600 dark:hover:text-rose-400 transition-all"
class="ml-0.5 btn btn-ghost btn-sm !px-1 !py-1 opacity-80 hover:opacity-100 hover:!text-rose-600 dark:hover:!text-rose-400 transition-all"
(click)="removeTag(tagState)"
aria-label="Supprimer">
<svg xmlns="http://www.w3.org/2000/svg" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M18 6 6 18"/><path d="m6 6 12 12"/></svg>
@@ -45,7 +45,7 @@
</ng-container>
<input data-tag-input
type="text"
class="min-w-[10ch] flex-1 bg-transparent text-sm outline-none placeholder:text-slate-400 dark:placeholder:text-slate-500 text-slate-900 dark:text-slate-100"
class="min-w-[10ch] flex-1 bg-transparent text-sm outline-none placeholder:text-muted dark:placeholder:text-muted text-main dark:text-main"
[value]="inputValue()"
(input)="onInput($event)"
(keydown)="onInputKeydown($event)"
@@ -55,7 +55,7 @@
<!-- Bouton Fermer l'édition -->
<button type="button"
class="flex-shrink-0 inline-flex items-center justify-center w-9 h-9 rounded-xl border border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-800 text-slate-600 dark:text-slate-400 hover:bg-slate-100 dark:hover:bg-slate-700 hover:text-slate-900 dark:hover:text-slate-200 transition-all duration-150 shadow-sm"
class="flex-shrink-0 btn btn-outline btn-sm w-9 h-9 !p-0"
(click)="exitEdit()"
[disabled]="saving()"
aria-label="Fermer l'édition"
@@ -67,25 +67,25 @@
</div>
<!-- Suggestions dropdown -->
<div *ngIf="menuOpen()" class="absolute left-0 top-[calc(100%+8px)] z-50 max-h-[280px] w-full min-w-[280px] overflow-auto rounded-xl border border-slate-200 dark:border-slate-700 bg-white dark:bg-slate-900 backdrop-blur-sm shadow-2xl animate-in fade-in slide-in-from-top-2 duration-200">
<div *ngIf="menuOpen()" class="absolute left-0 top-[calc(100%+8px)] z-50 max-h-[280px] w-full min-w-[280px] overflow-auto rounded-xl border border-border dark:border-border bg-card dark:bg-main backdrop-blur-sm shadow-2xl animate-in fade-in slide-in-from-top-2 duration-200">
<ng-container *ngIf="suggestions().length; else createTpl">
<button type="button"
*ngFor="let s of suggestions(); let i = index"
(mousedown)="$event.preventDefault()"
(click)="pickSuggestion(i)"
class="block w-full text-left px-3 py-2 text-sm transition-all duration-150 hover:bg-slate-100 dark:hover:bg-slate-800 hover:pl-4 text-slate-900 dark:text-slate-100"
class="block w-full text-left px-3 py-2 text-sm transition-all duration-150 hover:bg-surface1 dark:hover:bg-card hover:pl-4 text-main dark:text-main"
[ngClass]="{
'bg-sky-50 dark:bg-sky-900/20 border-l-2 border-sky-500': menuIndex() === i,
'opacity-40 cursor-not-allowed': isSelected(s)
}"
[attr.aria-disabled]="isSelected(s)">
<span class="font-medium">{{ s }}</span>
<span *ngIf="isSelected(s)" class="float-right text-xs text-slate-400 dark:text-slate-500 italic">✓ sélectionné</span>
<span *ngIf="isSelected(s)" class="float-right text-xs text-muted dark:text-muted italic">✓ sélectionné</span>
</button>
</ng-container>
<ng-template #createTpl>
<button type="button"
class="block w-full text-left px-3 py-2 text-sm text-sky-600 dark:text-sky-400 hover:bg-slate-100 dark:hover:bg-slate-800 font-medium"
class="block w-full text-left px-3 py-2 text-sm text-link hover:bg-surface1 dark:hover:bg-card font-medium"
(mousedown)="$event.preventDefault()"
(click)="pickSuggestion(-1)">
<span class="opacity-60">+</span> Créer « {{ inputValue().trim() }} »
@@ -1,23 +1,23 @@
<div class="fixed inset-0 z-50 flex items-center justify-center">
<div class="absolute inset-0 bg-black/40"></div>
<div class="relative rounded-2xl shadow-xl border border-slate-200 dark:border-slate-700 bg-white dark:bg-slate-900 p-4 md:p-5 w-[min(780px,92vw)]">
<div class="relative rounded-2xl shadow-xl border border-border dark:border-border bg-card dark:bg-main p-4 md:p-5 w-[min(780px,92vw)]">
<div class="flex items-center justify-between mb-3">
<div class="flex items-center gap-2">
<svg class="h-5 w-5 text-slate-500" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M7 7h.01"/><path d="M3 7h5a2 2 0 0 1 1.414.586l7 7a2 2 0 0 1 0 2.828l-3.172 3.172a2 2 0 0 1-2.828 0l-7-7A2 2 0 0 1 3 12V7Z"/></svg>
<svg class="h-5 w-5 text-muted" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M7 7h.01"/><path d="M3 7h5a2 2 0 0 1 1.414.586l7 7a2 2 0 0 1 0 2.828l-3.172 3.172a2 2 0 0 1-2.828 0l-7-7A2 2 0 0 1 3 12V7Z"/></svg>
<h3 class="text-base font-semibold">Éditer les tags</h3>
<span class="text-sm text-slate-500">({{ count() }})</span>
<span class="text-sm text-muted">({{ count() }})</span>
</div>
<button type="button" class="inline-flex items-center justify-center w-8 h-8 rounded-full hover:bg-slate-500/10 dark:hover:bg-slate-200/10" (click)="close.emit()" aria-label="Fermer">
<button type="button" class="inline-flex items-center justify-center w-8 h-8 rounded-full hover:bg-surface1 dark:hover:bg-surface2" (click)="close.emit()" aria-label="Fermer">
<svg class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 6 6 18"/><path d="m6 6 12 12"/></svg>
</button>
</div>
<div class="flex flex-col gap-3">
<div class="flex flex-wrap gap-2 rounded-xl border border-slate-200 dark:border-slate-700 p-3 min-h-[44px]">
<div class="flex flex-wrap gap-2 rounded-xl border border-border dark:border-border p-3 min-h-[44px]">
@for (t of working(); track t) {
<span class="inline-flex items-center gap-2 rounded-full px-3 py-1.5 text-sm font-medium bg-slate-100 dark:bg-slate-800">
<span class="inline-flex items-center gap-2 rounded-full px-3 py-1.5 text-sm font-medium bg-surface1 dark:bg-card">
{{ t }}
<button type="button" class="w-6 h-6 inline-flex items-center justify-center rounded-full hover:bg-slate-500/10 dark:hover:bg-slate-200/10" (click)="removeTag(t)" aria-label="Retirer">
<button type="button" class="w-6 h-6 inline-flex items-center justify-center rounded-full hover:bg-surface1 dark:hover:bg-surface2" (click)="removeTag(t)" aria-label="Retirer">
<svg class="h-3.5 w-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 6 6 18"/><path d="m6 6 12 12"/></svg>
</button>
</span>
@@ -25,15 +25,15 @@
<input data-tag-input type="text" [value]="inputValue()" (input)="inputValue.set($any($event.target).value)" (keydown)="onKeydown($event)" placeholder="Ajouter un tag..." class="min-w-[200px] flex-1 bg-transparent outline-none px-2 py-1.5" />
</div>
<div class="rounded-xl border border-slate-200 dark:border-slate-700 overflow-hidden">
<div class="rounded-xl border border-border dark:border-border overflow-hidden">
<div class="max-h-64 overflow-y-auto">
@if (suggestions().length === 0) {
<div class="px-3 py-2 text-sm text-slate-500">Aucune suggestion</div>
<div class="px-3 py-2 text-sm text-muted">Aucune suggestion</div>
} @else {
<ul>
@for (s of suggestions(); track s) {
<li>
<button type="button" class="w-full text-left px-3 py-2 hover:bg-slate-50 dark:hover:bg-slate-800" (click)="pickSuggestion(s)">{{ s }}</button>
<button type="button" class="w-full text-left px-3 py-2 hover:bg-surface1 dark:hover:bg-card" (click)="pickSuggestion(s)">{{ s }}</button>
</li>
}
</ul>
@@ -42,8 +42,8 @@
</div>
<div class="flex items-center justify-end gap-2 pt-1">
<button type="button" class="px-3 py-1.5 rounded-lg border border-slate-300 dark:border-slate-700 hover:bg-slate-50 dark:hover:bg-slate-800" (click)="close.emit()">Annuler</button>
<button type="button" class="px-3 py-1.5 rounded-lg bg-blue-600 hover:bg-blue-700 text-white disabled:opacity-60" [disabled]="saving()" (click)="save()">Enregistrer</button>
<button type="button" class="px-3 py-1.5 rounded-lg border border-border dark:border-border hover:bg-surface1 dark:hover:bg-card" (click)="close.emit()">Annuler</button>
<button type="button" class="px-3 py-1.5 rounded-lg bg-primary hover:bg-brand-700 text-white disabled:opacity-60" [disabled]="saving()" (click)="save()">Enregistrer</button>
</div>
</div>
</div>
@@ -1,7 +1,7 @@
<div class="flex items-center gap-2">
<button
type="button"
class="inline-flex items-center justify-center w-9 h-9 md:w-9 md:h-9 sm:w-8 sm:h-8 rounded-full hover:bg-slate-500/10 dark:hover:bg-slate-200/10"
class="inline-flex items-center justify-center w-9 h-9 md:w-9 md:h-9 sm:w-8 sm:h-8 rounded-full hover:bg-slate-500/10 dark:hover:bg-surface2/10"
aria-label="Modifier les tags"
[attr.aria-pressed]="isEditing()"
(click)="toggleEditor()"
@@ -12,7 +12,7 @@
<div class="flex flex-wrap items-center gap-1">
<button
type="button"
class="px-3 py-1.5 rounded-full bg-slate-700/10 dark:bg-slate-300/10 text-sm font-medium hover:bg-slate-500/10 dark:hover:bg-slate-200/10"
class="px-3 py-1.5 rounded-full bg-slate-700/10 dark:bg-muted/10 text-sm font-medium hover:bg-slate-500/10 dark:hover:bg-surface2/10"
*ngFor="let tag of normalizedTags()"
(click)="onChipClick(tag)"
[title]="'Voir les notes #'+tag">
@@ -2,7 +2,7 @@
<div class="pointer-events-none fixed bottom-4 right-4 z-[9999] flex w-[min(92vw,420px)] flex-col gap-2">
<div
*ngFor="let t of toasts()"
class="pointer-events-auto overflow-hidden rounded-2xl border shadow-lg transition-all duration-300 ease-out data-[closing=true]:translate-y-2 data-[closing=true]:opacity-0 bg-slate-800/90 text-slate-50 border-slate-700"
class="pointer-events-auto overflow-hidden rounded-2xl border shadow-lg transition-all duration-300 ease-out data-[closing=true]:translate-y-2 data-[closing=true]:opacity-0 bg-card text-main border-border"
[attr.data-closing]="t.closing ? 'true' : 'false'"
>
<!-- Contenu -->
@@ -14,17 +14,17 @@
<span *ngIf="t.type==='error'">⛔</span>
</div>
<div class="flex-1 text-sm leading-relaxed">{{ t.message }}</div>
<button class="ml-1 rounded-md px-2 py-1 text-xs opacity-70 hover:opacity-100" (click)="dismiss(t.id)" aria-label="Fermer">✕</button>
<button class="ml-1 rounded-md px-2 py-1 text-xs opacity-70 hover:opacity-100 text-muted hover:text-main" (click)="dismiss(t.id)" aria-label="Fermer">✕</button>
</div>
<!-- Barre de progression en haut -->
<div class="relative h-1 w-full bg-transparent">
<div class="absolute left-0 top-0 h-1 transition-[width] duration-100 ease-linear"
[ngClass]="{
'bg-sky-500': t.type==='info',
'bg-emerald-500': t.type==='success',
'bg-amber-500': t.type==='warning',
'bg-rose-500': t.type==='error'
'bg-info': t.type==='info',
'bg-success': t.type==='success',
'bg-warning': t.type==='warning',
'bg-danger': t.type==='error'
}"
[style.width.%]="t.progress * 100"></div>
</div>