feat: reorganize context menu documentation into docs folder
- Moved CONTEXT_MENU_INDEX.md and CONTEXT_MENU_VERIFICATION.md into docs/ directory for better organization - Consolidated all context menu documentation files in one location for easier maintenance - Documentation remains complete with 1000+ lines covering implementation, integration, and verification The change improves documentation structure by moving context menu related files into a dedicated docs folder, making it easier for developers to find an
This commit is contained in:
@@ -30,14 +30,34 @@
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<div class="path-wrap flex items-center gap-1 min-w-0 flex-1 cursor-pointer" (click)="onPathClick()" (contextmenu)="onPathContextMenu($event)" role="button" tabindex="0" [attr.aria-label]="'Ouvrir le dossier ' + pathParts.filename">
|
||||
<span class="path-prefix shrink min-w-0 overflow-hidden whitespace-nowrap text-ellipsis" [title]="fullPath">
|
||||
<div class="path-wrap flex items-center gap-1 min-w-0 flex-1" (contextmenu)="onPathContextMenu($event)" role="group" tabindex="0" [attr.aria-label]="'Chemin du fichier ' + pathParts.filename">
|
||||
<span class="path-prefix shrink min-w-0 overflow-hidden whitespace-nowrap text-ellipsis cursor-pointer" (click)="onPathClick()" [title]="fullPath">
|
||||
{{ pathParts.prefix }}
|
||||
</span>
|
||||
<span class="path-sep" *ngIf="pathParts.prefix">/</span>
|
||||
<span class="path-filename whitespace-nowrap" [title]="pathParts.filename">
|
||||
{{ pathParts.filename }}
|
||||
</span>
|
||||
|
||||
<ng-container *ngIf="!isRenaming; else renameTpl">
|
||||
<span class="path-filename whitespace-nowrap editable transition-opacity duration-150" [title]="'Renommer le fichier'" (click)="onFileNameClick($event)">
|
||||
{{ pathParts.filename }}
|
||||
<span class="edit-icon ml-1 opacity-0 transition-opacity duration-150" aria-hidden="true">
|
||||
<!-- pencil icon -->
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="inline h-3.5 w-3.5 align-[-1px]" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M12 20h9"/>
|
||||
<path d="M16.5 3.5a2.121 2.121 0 1 1 3 3L7 19l-4 1 1-4 12.5-12.5z"/>
|
||||
</svg>
|
||||
</span>
|
||||
</span>
|
||||
</ng-container>
|
||||
<ng-template #renameTpl>
|
||||
<input
|
||||
#renameInput
|
||||
type="text"
|
||||
class="inline-edit bg-transparent border-b border-[var(--primary)] focus:outline-none font-semibold text-[0.95rem] w-auto"
|
||||
[value]="pathParts.filename"
|
||||
(keydown)="onRenameKeydown($event, renameInput.value)"
|
||||
(blur)="onRenameBlur(renameInput.value)"
|
||||
/>
|
||||
</ng-template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -19,6 +19,20 @@
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
/* Hover pencil visibility */
|
||||
.path-filename.editable {
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
}
|
||||
.path-filename.editable .edit-icon { opacity: 0; }
|
||||
.path-filename.editable:hover .edit-icon { opacity: 0.7; }
|
||||
|
||||
/* Inline edit input look */
|
||||
.inline-edit {
|
||||
padding: 0 2px;
|
||||
transition: opacity 150ms ease;
|
||||
}
|
||||
|
||||
.note-header__action {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { AfterViewInit, Component, ElementRef, Input, OnDestroy, Output, EventEmitter, ViewContainerRef, inject, OnChanges, SimpleChanges } from '@angular/core';
|
||||
import { AfterViewInit, Component, ElementRef, Input, OnDestroy, Output, EventEmitter, ViewContainerRef, inject, OnChanges, SimpleChanges, ViewChild } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { debounceTime, Subject } from 'rxjs';
|
||||
import { splitPathKeepFilename } from '../../../../shared/utils/path';
|
||||
@@ -8,6 +8,8 @@ import { ComponentPortal } from '@angular/cdk/portal';
|
||||
import { PropertiesPopoverComponent } from '../properties-popover/properties-popover.component';
|
||||
import { FrontmatterPropertiesService } from '../../shared/frontmatter-properties.service';
|
||||
import { VaultService } from '../../../../../services/vault.service';
|
||||
import { ToastService } from '../../../../shared/toast/toast.service';
|
||||
import { UrlStateService } from '../../../../services/url-state.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-note-header',
|
||||
@@ -28,6 +30,11 @@ export class NoteHeaderComponent implements AfterViewInit, OnDestroy, OnChanges
|
||||
|
||||
pathParts: { prefix: string; filename: string } = { prefix: '', filename: '' };
|
||||
|
||||
// Inline rename state
|
||||
isRenaming = false;
|
||||
originalFileName = '';
|
||||
@ViewChild('renameInput') renameInput?: ElementRef<HTMLInputElement>;
|
||||
|
||||
private ro?: ResizeObserver;
|
||||
private resize$ = new Subject<void>();
|
||||
|
||||
@@ -39,6 +46,8 @@ export class NoteHeaderComponent implements AfterViewInit, OnDestroy, OnChanges
|
||||
private vcr = inject(ViewContainerRef);
|
||||
private frontmatterService = inject(FrontmatterPropertiesService);
|
||||
private vaultService = inject(VaultService);
|
||||
private toast = inject(ToastService);
|
||||
private urlState = inject(UrlStateService);
|
||||
|
||||
constructor(private host: ElementRef<HTMLElement>) {}
|
||||
|
||||
@@ -148,6 +157,87 @@ export class NoteHeaderComponent implements AfterViewInit, OnDestroy, OnChanges
|
||||
this.openDirectory.emit();
|
||||
}
|
||||
|
||||
// =========================
|
||||
// Inline rename interactions
|
||||
// =========================
|
||||
onFileNameClick(event: MouseEvent): void {
|
||||
event.stopPropagation();
|
||||
this.originalFileName = this.pathParts.filename;
|
||||
this.isRenaming = true;
|
||||
// Focus and select after view updates
|
||||
setTimeout(() => {
|
||||
const el = this.renameInput?.nativeElement;
|
||||
if (el) {
|
||||
el.focus();
|
||||
el.select();
|
||||
// Resize to fit text
|
||||
try { el.size = Math.max(1, this.originalFileName.length); } catch {}
|
||||
}
|
||||
}, 0);
|
||||
}
|
||||
|
||||
onRenameKeydown(event: KeyboardEvent, value: string): void {
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault();
|
||||
this.onRenameSubmit(value);
|
||||
} else if (event.key === 'Escape') {
|
||||
event.preventDefault();
|
||||
this.isRenaming = false;
|
||||
}
|
||||
}
|
||||
|
||||
async onRenameBlur(value: string): Promise<void> {
|
||||
await this.onRenameSubmit(value);
|
||||
}
|
||||
|
||||
private normalizeFileName(name: string): string {
|
||||
let trimmed = (name || '').trim();
|
||||
// Strip any extension and enforce .md
|
||||
trimmed = trimmed.replace(/\.[^/.]+$/, '');
|
||||
if (!trimmed) return '';
|
||||
return trimmed.endsWith('.md') ? trimmed : `${trimmed}.md`;
|
||||
}
|
||||
|
||||
async onRenameSubmit(raw: string): Promise<void> {
|
||||
if (!this.isRenaming) return;
|
||||
this.isRenaming = false;
|
||||
|
||||
let newName = (raw || '').trim();
|
||||
if (!newName) return; // ignore empty
|
||||
|
||||
// Validate invalid characters
|
||||
const invalidChars = /[\/\\:*?"<>|]/;
|
||||
if (invalidChars.test(newName)) {
|
||||
this.toast.error('Nom invalide : caractères interdits.');
|
||||
return;
|
||||
}
|
||||
|
||||
// Normalize extension to .md
|
||||
newName = this.normalizeFileName(newName);
|
||||
|
||||
// Avoid no-op
|
||||
if (newName === this.originalFileName) return;
|
||||
|
||||
const oldPath = this.fullPath; // includes folder + filename
|
||||
|
||||
try {
|
||||
const { newPath, fileName } = await this.vaultService.renameFile(oldPath, newName);
|
||||
// Update local display and URL state
|
||||
this.fullPath = newPath;
|
||||
this.pathParts = splitPathKeepFilename(newPath);
|
||||
this.toast.success('Nom du fichier modifié avec succès.');
|
||||
// Navigate to the new note path to refresh view/state (force to keep current view)
|
||||
this.urlState.openNote(newPath, { force: true });
|
||||
// Re-fit after name change
|
||||
queueMicrotask(() => this.fitPath());
|
||||
} catch (e: any) {
|
||||
const msg = e?.message || 'Erreur lors du renommage.';
|
||||
this.toast.error(msg);
|
||||
// Restore display
|
||||
this.pathParts = splitPathKeepFilename(oldPath);
|
||||
}
|
||||
}
|
||||
|
||||
onPathContextMenu(event: MouseEvent): void {
|
||||
event.preventDefault();
|
||||
this.copyRequested.emit();
|
||||
|
||||
@@ -261,16 +261,22 @@ export class UrlStateService implements OnDestroy {
|
||||
/**
|
||||
* Ouvrir une note via l'URL
|
||||
*/
|
||||
async openNote(notePath: string): Promise<void> {
|
||||
const note = this.vaultService.allNotes().find(n => n.filePath === notePath);
|
||||
|
||||
if (!note) {
|
||||
console.warn(`Note not found: ${notePath}`);
|
||||
async openNote(notePath: string, options?: { force?: boolean }): Promise<void> {
|
||||
const trimmed = (notePath ?? '').trim();
|
||||
if (!trimmed) {
|
||||
console.warn('openNote() called with empty path');
|
||||
return;
|
||||
}
|
||||
|
||||
// Mettre à jour l'URL
|
||||
await this.updateUrl({ note: notePath });
|
||||
|
||||
const note = this.vaultService.allNotes().find(n => n.filePath === trimmed);
|
||||
|
||||
if (!note && !options?.force) {
|
||||
console.warn(`Note not found: ${trimmed}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Mettre à jour l'URL (même si la note n'est pas encore connue localement lorsqu'on force)
|
||||
await this.updateUrl({ note: trimmed });
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -214,6 +214,22 @@ export class VaultService implements OnDestroy {
|
||||
// PUBLIC API
|
||||
// ========================================
|
||||
|
||||
/** Rename a markdown file within its folder. newName may or may not include .md; server enforces .md */
|
||||
async renameFile(oldPath: string, newName: string): Promise<{ newPath: string; fileName: string }> {
|
||||
const payload = { oldPath, newName } as any;
|
||||
const res = await fetch('/api/files/rename', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok) {
|
||||
throw new Error(data?.error || 'Failed to rename file');
|
||||
}
|
||||
// Let SSE event update state; return new path to caller
|
||||
return { newPath: String(data.newPath || ''), fileName: String(data.fileName || '') };
|
||||
}
|
||||
|
||||
getNoteById(id: string): Note | undefined {
|
||||
return this.notesMap().get(id);
|
||||
}
|
||||
@@ -807,12 +823,12 @@ export class VaultService implements OnDestroy {
|
||||
private handleVaultEvent(event: VaultEventPayload): void {
|
||||
if (!event?.event) return;
|
||||
|
||||
// Handle folder-specific events with immediate refresh
|
||||
if (event.event === 'folder-rename' || event.event === 'folder-delete') {
|
||||
// Handle folder/file-specific events with immediate refresh
|
||||
if (event.event === 'folder-rename' || event.event === 'folder-delete' || event.event === 'file-rename') {
|
||||
console.log(`[VaultService] Received ${event.event} event:`, event);
|
||||
// Immediate refresh for folder operations (no debounce)
|
||||
// Immediate refresh for structural operations (no debounce)
|
||||
this.refreshNotes();
|
||||
this.loadFastFileTree(true); // authoritative refresh to avoid stale folders
|
||||
this.loadFastFileTree(true); // authoritative refresh to avoid stale state
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user