feat: add note moving functionality with UI controls
- Added new API endpoint /api/vault/notes/move for moving markdown files between folders - Implemented setupMoveNoteEndpoint with path validation, error handling, and event broadcasting - Added move note UI component to note header with folder selection - Updated note viewer to handle note path changes after moving - Added moveNoteToFolder method to VaultService for client-side integration - Modified note header layout to include move trigger
This commit is contained in:
parent
b1da9b111d
commit
0ae9cae1eb
@ -18,6 +18,7 @@ import path from 'path';
|
|||||||
// ============================================================================
|
// ============================================================================
|
||||||
// ENDPOINT X: /api/files/rename - Rename a markdown file within the same folder
|
// ENDPOINT X: /api/files/rename - Rename a markdown file within the same folder
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
export function setupRenameFileEndpoint(app, vaultDir, broadcastVaultEvent, metadataCache) {
|
export function setupRenameFileEndpoint(app, vaultDir, broadcastVaultEvent, metadataCache) {
|
||||||
app.put('/api/files/rename', express.json(), (req, res) => {
|
app.put('/api/files/rename', express.json(), (req, res) => {
|
||||||
try {
|
try {
|
||||||
@ -84,6 +85,84 @@ export function setupRenameFileEndpoint(app, vaultDir, broadcastVaultEvent, meta
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// ENDPOINT X+1: /api/vault/notes/move - Move a markdown file to another folder
|
||||||
|
// ============================================================================
|
||||||
|
export function setupMoveNoteEndpoint(app, vaultDir, broadcastVaultEvent, metadataCache) {
|
||||||
|
app.post('/api/vault/notes/move', express.json(), (req, res) => {
|
||||||
|
try {
|
||||||
|
const { notePath, newFolderPath } = req.body || {};
|
||||||
|
|
||||||
|
if (!notePath || typeof notePath !== 'string') {
|
||||||
|
return res.status(400).json({ error: 'Missing or invalid notePath' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const sanitizePath = (value = '') => String(value).replace(/\\/g, '/').replace(/^\/+|\/+$/g, '');
|
||||||
|
const sanitizedNotePath = sanitizePath(notePath.endsWith('.md') ? notePath : `${notePath}.md`);
|
||||||
|
if (!sanitizedNotePath || sanitizedNotePath.includes('..')) {
|
||||||
|
return res.status(400).json({ error: 'Invalid notePath' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const sourceAbs = path.join(vaultDir, sanitizedNotePath);
|
||||||
|
if (!fs.existsSync(sourceAbs) || !fs.statSync(sourceAbs).isFile()) {
|
||||||
|
return res.status(404).json({ error: 'Source note not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const sanitizedFolder = sanitizePath(typeof newFolderPath === 'string' ? newFolderPath : '');
|
||||||
|
if (sanitizedFolder.includes('..')) {
|
||||||
|
return res.status(400).json({ error: 'Invalid destination folder' });
|
||||||
|
}
|
||||||
|
if (sanitizedFolder.startsWith('__builtin__') || sanitizedFolder.startsWith('.trash')) {
|
||||||
|
return res.status(400).json({ error: 'Destination folder is not allowed' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const destinationDir = sanitizedFolder ? path.join(vaultDir, sanitizedFolder) : vaultDir;
|
||||||
|
try {
|
||||||
|
fs.mkdirSync(destinationDir, { recursive: true });
|
||||||
|
} catch (mkErr) {
|
||||||
|
console.error('[POST /api/vault/notes/move] Failed to ensure destination directory:', mkErr);
|
||||||
|
return res.status(500).json({ error: 'Failed to prepare destination folder' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const fileName = path.basename(sourceAbs);
|
||||||
|
const destinationAbs = path.join(destinationDir, fileName);
|
||||||
|
if (sourceAbs === destinationAbs) {
|
||||||
|
return res.status(400).json({ error: 'Destination is same as source' });
|
||||||
|
}
|
||||||
|
if (fs.existsSync(destinationAbs)) {
|
||||||
|
return res.status(409).json({ error: 'A note with this name already exists in the destination folder' });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
fs.renameSync(sourceAbs, destinationAbs);
|
||||||
|
} catch (renameErr) {
|
||||||
|
console.error('[POST /api/vault/notes/move] Move operation failed:', renameErr);
|
||||||
|
return res.status(500).json({ error: 'Failed to move note' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const newRelPath = path.relative(vaultDir, destinationAbs).replace(/\\/g, '/');
|
||||||
|
|
||||||
|
try { metadataCache?.clear?.(); } catch {}
|
||||||
|
|
||||||
|
try {
|
||||||
|
broadcastVaultEvent?.({
|
||||||
|
event: 'file-move',
|
||||||
|
oldPath: sanitizedNotePath,
|
||||||
|
newPath: newRelPath,
|
||||||
|
timestamp: Date.now()
|
||||||
|
});
|
||||||
|
} catch (evtErr) {
|
||||||
|
console.warn('[POST /api/vault/notes/move] Failed to broadcast event:', evtErr);
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.json({ success: true, oldPath: sanitizedNotePath, newPath: newRelPath });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[POST /api/vault/notes/move] Unexpected error:', error);
|
||||||
|
return res.status(500).json({ error: 'Internal server error' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// ENDPOINT 5: /api/folders/rename - Rename folder with validation
|
// ENDPOINT 5: /api/folders/rename - Rename folder with validation
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|||||||
@ -36,7 +36,8 @@ import {
|
|||||||
setupRenameFolderEndpoint,
|
setupRenameFolderEndpoint,
|
||||||
setupDeleteFolderEndpoint,
|
setupDeleteFolderEndpoint,
|
||||||
setupCreateFolderEndpoint,
|
setupCreateFolderEndpoint,
|
||||||
setupRenameFileEndpoint
|
setupRenameFileEndpoint,
|
||||||
|
setupMoveNoteEndpoint
|
||||||
} from './index-phase3-patch.mjs';
|
} from './index-phase3-patch.mjs';
|
||||||
|
|
||||||
const __filename = fileURLToPath(import.meta.url);
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
@ -1540,6 +1541,9 @@ setupRenameFolderEndpoint(app, vaultDir, broadcastVaultEvent, metadataCache);
|
|||||||
// Setup rename file endpoint (must be before catch-all)
|
// Setup rename file endpoint (must be before catch-all)
|
||||||
setupRenameFileEndpoint(app, vaultDir, broadcastVaultEvent, metadataCache);
|
setupRenameFileEndpoint(app, vaultDir, broadcastVaultEvent, metadataCache);
|
||||||
|
|
||||||
|
// Setup move note endpoint (must be before catch-all)
|
||||||
|
setupMoveNoteEndpoint(app, vaultDir, broadcastVaultEvent, metadataCache);
|
||||||
|
|
||||||
// Setup delete folder endpoint (must be before catch-all)
|
// Setup delete folder endpoint (must be before catch-all)
|
||||||
setupDeleteFolderEndpoint(app, vaultDir, broadcastVaultEvent, metadataCache);
|
setupDeleteFolderEndpoint(app, vaultDir, broadcastVaultEvent, metadataCache);
|
||||||
|
|
||||||
|
|||||||
@ -0,0 +1,352 @@
|
|||||||
|
import { CommonModule } from '@angular/common';
|
||||||
|
import {
|
||||||
|
Component,
|
||||||
|
DestroyRef,
|
||||||
|
ElementRef,
|
||||||
|
EventEmitter,
|
||||||
|
Input,
|
||||||
|
Output,
|
||||||
|
ViewChild,
|
||||||
|
computed,
|
||||||
|
effect,
|
||||||
|
inject,
|
||||||
|
signal
|
||||||
|
} from '@angular/core';
|
||||||
|
import { splitPathKeepFilename } from '../../../../shared/utils/path';
|
||||||
|
import { ToastService } from '../../../../shared/toast/toast.service';
|
||||||
|
import { VaultService } from '../../../../../services/vault.service';
|
||||||
|
import { VaultNode } from '../../../../../types';
|
||||||
|
|
||||||
|
interface FolderEntry {
|
||||||
|
name: string;
|
||||||
|
path: string;
|
||||||
|
children: FolderEntry[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FlattenedFolder {
|
||||||
|
name: string;
|
||||||
|
path: string;
|
||||||
|
breadcrumb: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-move-note-to-folder',
|
||||||
|
standalone: true,
|
||||||
|
imports: [CommonModule],
|
||||||
|
templateUrl: './move-note-to-folder.component.html'
|
||||||
|
})
|
||||||
|
export class MoveNoteToFolderComponent {
|
||||||
|
@Input()
|
||||||
|
set currentPath(value: string | null | undefined) {
|
||||||
|
this.currentFolderPath.set(this.normalizeFolderPath(value));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Input() notePath!: string;
|
||||||
|
|
||||||
|
@Output() noteMoved = new EventEmitter<string>();
|
||||||
|
@Output() openFolderRequested = new EventEmitter<void>();
|
||||||
|
|
||||||
|
@ViewChild('searchField') searchField?: ElementRef<HTMLInputElement>;
|
||||||
|
|
||||||
|
readonly showMenu = signal(false);
|
||||||
|
readonly searchQuery = signal('');
|
||||||
|
readonly breadcrumb = signal<FolderEntry[]>([]);
|
||||||
|
readonly loading = signal(true);
|
||||||
|
readonly processingPath = signal<string | null>(null);
|
||||||
|
readonly currentFolderPath = signal('');
|
||||||
|
readonly rootFolders = signal<FolderEntry[]>([]);
|
||||||
|
readonly currentLevelFolders = signal<FolderEntry[]>([]);
|
||||||
|
readonly flattenedFolders = signal<FlattenedFolder[]>([]);
|
||||||
|
|
||||||
|
readonly isSearching = computed(() => this.searchQuery().trim().length > 0);
|
||||||
|
|
||||||
|
readonly currentFolderLabel = computed(() => this.currentFolderPath() || 'All my folders');
|
||||||
|
|
||||||
|
readonly activeFolderPath = computed(() => {
|
||||||
|
const crumbs = this.breadcrumb();
|
||||||
|
return crumbs.length ? crumbs[crumbs.length - 1].path : '';
|
||||||
|
});
|
||||||
|
|
||||||
|
readonly canMoveHere = computed(() => !this.isSearching() && this.activeFolderPath() !== this.currentFolderPath());
|
||||||
|
|
||||||
|
readonly searchResults = computed<FlattenedFolder[]>(() => {
|
||||||
|
const query = this.searchQuery().trim().toLowerCase();
|
||||||
|
if (!query) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const results = this.flattenedFolders().filter(entry => {
|
||||||
|
const breadcrumb = entry.breadcrumb.join(' / ').toLowerCase();
|
||||||
|
return entry.name.toLowerCase().includes(query) || breadcrumb.includes(query);
|
||||||
|
});
|
||||||
|
|
||||||
|
if ('all my folders'.includes(query)) {
|
||||||
|
results.unshift({ name: 'All my folders', path: '', breadcrumb: [] });
|
||||||
|
}
|
||||||
|
|
||||||
|
return results;
|
||||||
|
});
|
||||||
|
|
||||||
|
private readonly vault = inject(VaultService);
|
||||||
|
private readonly toast = inject(ToastService);
|
||||||
|
private readonly destroyRef = inject(DestroyRef);
|
||||||
|
private readonly host = inject(ElementRef<HTMLElement>);
|
||||||
|
|
||||||
|
private listenersAttached = false;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
effect(
|
||||||
|
() => {
|
||||||
|
const nodes = this.vault.fastFileTree() as VaultNode[];
|
||||||
|
this.buildFolderSources(nodes ?? []);
|
||||||
|
},
|
||||||
|
{ allowSignalWrites: true }
|
||||||
|
);
|
||||||
|
|
||||||
|
effect(
|
||||||
|
() => {
|
||||||
|
const path = this.currentFolderPath();
|
||||||
|
const roots = this.rootFolders();
|
||||||
|
this.setActiveLevel(path, roots);
|
||||||
|
},
|
||||||
|
{ allowSignalWrites: true }
|
||||||
|
);
|
||||||
|
|
||||||
|
this.destroyRef.onDestroy(() => this.detachGlobalListeners());
|
||||||
|
}
|
||||||
|
|
||||||
|
toggleMenu(): void {
|
||||||
|
if (this.showMenu()) {
|
||||||
|
this.closeMenu();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.searchQuery.set('');
|
||||||
|
this.processingPath.set(null);
|
||||||
|
this.showMenu.set(true);
|
||||||
|
this.setActiveLevel(this.currentFolderPath(), this.rootFolders());
|
||||||
|
this.attachGlobalListeners();
|
||||||
|
|
||||||
|
queueMicrotask(() => this.focusSearchField());
|
||||||
|
}
|
||||||
|
|
||||||
|
openFolderInSidebar(): void {
|
||||||
|
this.openFolderRequested.emit();
|
||||||
|
this.closeMenu();
|
||||||
|
}
|
||||||
|
|
||||||
|
goToRoot(): void {
|
||||||
|
this.breadcrumb.set([]);
|
||||||
|
this.currentLevelFolders.set(this.rootFolders());
|
||||||
|
}
|
||||||
|
|
||||||
|
navigateInto(folder: FolderEntry, event: Event): void {
|
||||||
|
event.stopPropagation();
|
||||||
|
this.breadcrumb.update(prev => [...prev, folder]);
|
||||||
|
this.currentLevelFolders.set(folder.children ?? []);
|
||||||
|
}
|
||||||
|
|
||||||
|
navigateTo(level: number): void {
|
||||||
|
if (level <= 0) {
|
||||||
|
this.goToRoot();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const trail = this.breadcrumb().slice(0, level);
|
||||||
|
this.breadcrumb.set(trail);
|
||||||
|
const last = trail[trail.length - 1];
|
||||||
|
this.currentLevelFolders.set(last?.children ?? []);
|
||||||
|
}
|
||||||
|
|
||||||
|
onSearchChange(value: string): void {
|
||||||
|
this.searchQuery.set(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
clearSearch(): void {
|
||||||
|
this.searchQuery.set('');
|
||||||
|
queueMicrotask(() => this.focusSearchField());
|
||||||
|
}
|
||||||
|
|
||||||
|
async onFolderSelected(folder: FolderEntry, event?: Event): Promise<void> {
|
||||||
|
event?.stopPropagation();
|
||||||
|
await this.performMove(folder.path);
|
||||||
|
}
|
||||||
|
|
||||||
|
async onSearchResultSelected(result: FlattenedFolder): Promise<void> {
|
||||||
|
await this.performMove(result.path);
|
||||||
|
}
|
||||||
|
|
||||||
|
moveToCurrentFolder(): Promise<void> {
|
||||||
|
return this.performMove(this.activeFolderPath());
|
||||||
|
}
|
||||||
|
|
||||||
|
onEscapeKey(): void {
|
||||||
|
this.closeMenu();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async performMove(destinationPath: string): Promise<void> {
|
||||||
|
if (!this.notePath) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const target = this.normalizeFolderPath(destinationPath);
|
||||||
|
const current = this.currentFolderPath();
|
||||||
|
|
||||||
|
if (target === current || this.processingPath()) {
|
||||||
|
this.closeMenu();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.processingPath.set(target || '__root__');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { newPath } = await this.vault.moveNoteToFolder(this.notePath, target);
|
||||||
|
this.toast.success('Note moved successfully');
|
||||||
|
|
||||||
|
const { prefix } = splitPathKeepFilename(newPath);
|
||||||
|
this.currentFolderPath.set(this.normalizeFolderPath(prefix));
|
||||||
|
this.noteMoved.emit(newPath);
|
||||||
|
this.searchQuery.set('');
|
||||||
|
this.closeMenu();
|
||||||
|
} catch (error: unknown) {
|
||||||
|
const message = error instanceof Error ? error.message : 'Failed to move note';
|
||||||
|
this.toast.error(message);
|
||||||
|
} finally {
|
||||||
|
this.processingPath.set(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildFolderSources(nodes: VaultNode[]): void {
|
||||||
|
const folders = this.mapFolders(nodes);
|
||||||
|
this.rootFolders.set(folders);
|
||||||
|
const flattened: FlattenedFolder[] = [{ name: 'All my folders', path: '', breadcrumb: [] }];
|
||||||
|
this.collectFolders(folders, [], flattened);
|
||||||
|
this.flattenedFolders.set(flattened);
|
||||||
|
this.loading.set(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
private mapFolders(nodes: VaultNode[] | undefined): FolderEntry[] {
|
||||||
|
if (!nodes?.length) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const folders: FolderEntry[] = [];
|
||||||
|
for (const node of nodes) {
|
||||||
|
if (node.type !== 'folder') continue;
|
||||||
|
|
||||||
|
folders.push({
|
||||||
|
name: node.name,
|
||||||
|
path: this.normalizeFolderPath(node.path),
|
||||||
|
children: this.mapFolders(node.children)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return folders.sort((a, b) => a.name.localeCompare(b.name, undefined, { sensitivity: 'base' }));
|
||||||
|
}
|
||||||
|
|
||||||
|
private collectFolders(nodes: FolderEntry[], trail: string[], acc: FlattenedFolder[]): void {
|
||||||
|
for (const node of nodes) {
|
||||||
|
const breadcrumb = [...trail, node.name];
|
||||||
|
acc.push({ name: node.name, path: node.path, breadcrumb });
|
||||||
|
|
||||||
|
if (node.children?.length) {
|
||||||
|
this.collectFolders(node.children, breadcrumb, acc);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private setActiveLevel(path: string, roots: FolderEntry[]): void {
|
||||||
|
if (!roots.length) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!path) {
|
||||||
|
this.breadcrumb.set([]);
|
||||||
|
this.currentLevelFolders.set(roots);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const trail = this.findTrail(roots, path);
|
||||||
|
if (!trail) {
|
||||||
|
this.breadcrumb.set([]);
|
||||||
|
this.currentLevelFolders.set(roots);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.breadcrumb.set(trail);
|
||||||
|
const last = trail[trail.length - 1];
|
||||||
|
this.currentLevelFolders.set(last?.children ?? []);
|
||||||
|
}
|
||||||
|
|
||||||
|
private findTrail(nodes: FolderEntry[], target: string, trail: FolderEntry[] = []): FolderEntry[] | null {
|
||||||
|
for (const node of nodes) {
|
||||||
|
const nextTrail = [...trail, node];
|
||||||
|
if (node.path === target) {
|
||||||
|
return nextTrail;
|
||||||
|
}
|
||||||
|
if (node.children?.length) {
|
||||||
|
const found = this.findTrail(node.children, target, nextTrail);
|
||||||
|
if (found) {
|
||||||
|
return found;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private closeMenu(): void {
|
||||||
|
this.showMenu.set(false);
|
||||||
|
this.detachGlobalListeners();
|
||||||
|
this.searchQuery.set('');
|
||||||
|
this.processingPath.set(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
private focusSearchField(): void {
|
||||||
|
const el = this.searchField?.nativeElement;
|
||||||
|
if (el) {
|
||||||
|
el.focus();
|
||||||
|
el.select();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private attachGlobalListeners(): void {
|
||||||
|
if (typeof document === 'undefined' || this.listenersAttached) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
document.addEventListener('pointerdown', this.handleOutsidePointer, true);
|
||||||
|
document.addEventListener('keydown', this.handleEscape, true);
|
||||||
|
this.listenersAttached = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private detachGlobalListeners(): void {
|
||||||
|
if (typeof document === 'undefined' || !this.listenersAttached) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
document.removeEventListener('pointerdown', this.handleOutsidePointer, true);
|
||||||
|
document.removeEventListener('keydown', this.handleEscape, true);
|
||||||
|
this.listenersAttached = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private handleOutsidePointer = (event: PointerEvent) => {
|
||||||
|
if (!this.showMenu()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!this.host.nativeElement.contains(event.target as Node)) {
|
||||||
|
this.closeMenu();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
private handleEscape = (event: KeyboardEvent) => {
|
||||||
|
if (event.key === 'Escape' && this.showMenu()) {
|
||||||
|
this.closeMenu();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
private normalizeFolderPath(value: string | null | undefined): string {
|
||||||
|
return (value ?? '')
|
||||||
|
.replace(/\\/g, '/')
|
||||||
|
.replace(/^\/+/g, '')
|
||||||
|
.replace(/\/+$/, '')
|
||||||
|
.trim();
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,104 @@
|
|||||||
|
<div class="relative" (keydown.escape.stop)="onEscapeKey()">
|
||||||
|
<button
|
||||||
|
#trigger
|
||||||
|
type="button"
|
||||||
|
class="inline-flex items-center gap-1 text-sm text-muted hover:text-main transition-color"
|
||||||
|
(click)="toggleMenu()"
|
||||||
|
>
|
||||||
|
<span class="truncate" [title]="currentPath">
|
||||||
|
{{ currentFolderLabel() }}
|
||||||
|
</span>
|
||||||
|
<span class="text-xs opacity-70">▾</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
@if (showMenu()) {
|
||||||
|
<div class="fixed z-50 w-80 rounded-xl border border-border bg-card shadow-lg"
|
||||||
|
[style.top.px]="menuTop()" [style.left.px]="menuLeft()">
|
||||||
|
<div class="flex items-center justify-between px-3 py-2 border-b border-border/60">
|
||||||
|
<div class="text-xs uppercase tracking-wide text-muted">Move note to folder</div>
|
||||||
|
<button class="text-xs text-accent hover:underline" (click)="openFolderInSidebar()">Open sidebar</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="p-3 space-y-3">
|
||||||
|
<div class="flex items-center gap-2 bg-surface1 rounded-lg px-2 py-1.5">
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="text-muted">
|
||||||
|
<circle cx="11" cy="11" r="8"></circle>
|
||||||
|
<path d="m21 21-4.35-4.35"></path>
|
||||||
|
</svg>
|
||||||
|
<input
|
||||||
|
#searchField
|
||||||
|
type="text"
|
||||||
|
class="bg-transparent text-sm flex-1 outline-none"
|
||||||
|
placeholder="Search folders"
|
||||||
|
[value]="searchQuery()"
|
||||||
|
(input)="onSearchChange($any($event.target).value)"
|
||||||
|
/>
|
||||||
|
@if (searchQuery()) {
|
||||||
|
<button type="button" class="text-muted hover:text-main" (click)="clearSearch()">✕</button>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (isSearching() && searchResults().length === 0) {
|
||||||
|
<div class="text-xs text-muted px-1">No matching folders</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (!isSearching()) {
|
||||||
|
<div class="text-xs text-muted px-1">All my folders</div>
|
||||||
|
<div class="flex items-center gap-1 text-xs text-muted/80">
|
||||||
|
<button class="hover:text-main" (click)="goToRoot()">All my folders</button>
|
||||||
|
@for (crumb of breadcrumb(); track crumb.path; let i = $index) {
|
||||||
|
<span>›</span>
|
||||||
|
<button class="hover:text-main" (click)="navigateTo(i + 1)">{{ crumb.name }}</button>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="max-h-64 overflow-y-auto space-y-1">
|
||||||
|
@if (loading()) {
|
||||||
|
<div class="text-xs text-muted px-2 py-3">Loading folders…</div>
|
||||||
|
} @else {
|
||||||
|
@for (folder of currentLevelFolders(); track folder.path) {
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="w-full flex items-center justify-between px-2 py-1.5 rounded-lg text-sm hover:bg-surface1"
|
||||||
|
(click)="onFolderSelected(folder, $event)"
|
||||||
|
>
|
||||||
|
<span class="flex items-center gap-2">
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<path d="M3 7v10a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2V9a2 2 0 0 0-2-2h-6l-2-2H5a2 2 0 0 0-2 2z"></path>
|
||||||
|
</svg>
|
||||||
|
<span class="truncate">{{ folder.name }}</span>
|
||||||
|
</span>
|
||||||
|
@if (folder.children?.length) {
|
||||||
|
<button type="button" class="text-xs text-muted hover:text-main" (click)="navigateInto(folder, $event)">
|
||||||
|
▶ {{ folder.children.length }} subfolder{{ folder.children.length > 1 ? 's' : '' }}
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (isSearching()) {
|
||||||
|
<div class="max-h-64 overflow-y-auto space-y-1">
|
||||||
|
@for (result of searchResults(); track result.path) {
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="w-full flex flex-col items-start px-2 py-1.5 rounded-lg text-sm hover:bg-surface1 text-left"
|
||||||
|
(click)="onSearchResultSelected(result)"
|
||||||
|
>
|
||||||
|
<span class="font-medium">{{ result.name }}</span>
|
||||||
|
<span class="text-xs text-muted">{{ result.breadcrumb.join(' / ') }}</span>
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<div class="flex items-center justify-between pt-2 border-t border-border/60">
|
||||||
|
<button class="text-xs text-muted hover:text-main" (click)="moveToCurrentFolder()">Move here</button>
|
||||||
|
<button class="text-xs text-muted hover:text-main" (click)="closeMenu()">Cancel</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
@ -0,0 +1,369 @@
|
|||||||
|
import { CommonModule } from '@angular/common';
|
||||||
|
import {
|
||||||
|
Component,
|
||||||
|
DestroyRef,
|
||||||
|
ElementRef,
|
||||||
|
EventEmitter,
|
||||||
|
Input,
|
||||||
|
Output,
|
||||||
|
ViewChild,
|
||||||
|
computed,
|
||||||
|
effect,
|
||||||
|
inject,
|
||||||
|
signal
|
||||||
|
} from '@angular/core';
|
||||||
|
import { splitPathKeepFilename } from '../../../../shared/utils/path';
|
||||||
|
import { ToastService } from '../../../../shared/toast/toast.service';
|
||||||
|
import { VaultService } from '../../../../../services/vault.service';
|
||||||
|
import { VaultNode } from '../../../../../types';
|
||||||
|
|
||||||
|
interface FolderEntry {
|
||||||
|
name: string;
|
||||||
|
path: string;
|
||||||
|
children: FolderEntry[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FlattenedFolder {
|
||||||
|
name: string;
|
||||||
|
path: string;
|
||||||
|
breadcrumb: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-move-note-to-folder',
|
||||||
|
standalone: true,
|
||||||
|
imports: [CommonModule],
|
||||||
|
templateUrl: './move-note-to-folder.component.html'
|
||||||
|
})
|
||||||
|
export class MoveNoteToFolderComponent {
|
||||||
|
@Input()
|
||||||
|
set currentPath(value: string | null | undefined) {
|
||||||
|
this.currentFolderPath.set(this.normalizeFolderPath(value));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Input() notePath!: string;
|
||||||
|
|
||||||
|
@Output() noteMoved = new EventEmitter<string>();
|
||||||
|
@Output() openFolderRequested = new EventEmitter<void>();
|
||||||
|
|
||||||
|
@ViewChild('searchField') searchField?: ElementRef<HTMLInputElement>;
|
||||||
|
@ViewChild('trigger') trigger?: ElementRef<HTMLElement>;
|
||||||
|
|
||||||
|
readonly showMenu = signal(false);
|
||||||
|
readonly searchQuery = signal('');
|
||||||
|
readonly breadcrumb = signal<FolderEntry[]>([]);
|
||||||
|
readonly loading = signal(true);
|
||||||
|
readonly processingPath = signal<string | null>(null);
|
||||||
|
readonly currentFolderPath = signal('');
|
||||||
|
readonly rootFolders = signal<FolderEntry[]>([]);
|
||||||
|
readonly currentLevelFolders = signal<FolderEntry[]>([]);
|
||||||
|
readonly flattenedFolders = signal<FlattenedFolder[]>([]);
|
||||||
|
readonly menuTop = signal(0);
|
||||||
|
readonly menuLeft = signal(0);
|
||||||
|
|
||||||
|
readonly isSearching = computed(() => this.searchQuery().trim().length > 0);
|
||||||
|
|
||||||
|
readonly currentFolderLabel = computed(() => this.currentFolderPath() || 'All my folders');
|
||||||
|
|
||||||
|
readonly activeFolderPath = computed(() => {
|
||||||
|
const crumbs = this.breadcrumb();
|
||||||
|
return crumbs.length ? crumbs[crumbs.length - 1].path : '';
|
||||||
|
});
|
||||||
|
|
||||||
|
readonly canMoveHere = computed(() => !this.isSearching() && this.activeFolderPath() !== this.currentFolderPath());
|
||||||
|
|
||||||
|
readonly searchResults = computed<FlattenedFolder[]>(() => {
|
||||||
|
const query = this.searchQuery().trim().toLowerCase();
|
||||||
|
if (!query) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const results = this.flattenedFolders().filter(entry => {
|
||||||
|
const breadcrumb = entry.breadcrumb.join(' / ').toLowerCase();
|
||||||
|
return entry.name.toLowerCase().includes(query) || breadcrumb.includes(query);
|
||||||
|
});
|
||||||
|
|
||||||
|
if ('all my folders'.includes(query)) {
|
||||||
|
results.unshift({ name: 'All my folders', path: '', breadcrumb: [] });
|
||||||
|
}
|
||||||
|
|
||||||
|
return results;
|
||||||
|
});
|
||||||
|
|
||||||
|
private readonly vault = inject(VaultService);
|
||||||
|
private readonly toast = inject(ToastService);
|
||||||
|
private readonly destroyRef = inject(DestroyRef);
|
||||||
|
private readonly host = inject(ElementRef<HTMLElement>);
|
||||||
|
|
||||||
|
private listenersAttached = false;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
effect(
|
||||||
|
() => {
|
||||||
|
const nodes = this.vault.fastFileTree() as VaultNode[];
|
||||||
|
this.buildFolderSources(nodes ?? []);
|
||||||
|
},
|
||||||
|
{ allowSignalWrites: true }
|
||||||
|
);
|
||||||
|
|
||||||
|
effect(
|
||||||
|
() => {
|
||||||
|
const path = this.currentFolderPath();
|
||||||
|
const roots = this.rootFolders();
|
||||||
|
this.setActiveLevel(path, roots);
|
||||||
|
},
|
||||||
|
{ allowSignalWrites: true }
|
||||||
|
);
|
||||||
|
|
||||||
|
this.destroyRef.onDestroy(() => this.detachGlobalListeners());
|
||||||
|
}
|
||||||
|
|
||||||
|
toggleMenu(): void {
|
||||||
|
if (this.showMenu()) {
|
||||||
|
this.closeMenu();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.searchQuery.set('');
|
||||||
|
this.processingPath.set(null);
|
||||||
|
this.showMenu.set(true);
|
||||||
|
this.setActiveLevel(this.currentFolderPath(), this.rootFolders());
|
||||||
|
this.attachGlobalListeners();
|
||||||
|
|
||||||
|
queueMicrotask(() => {
|
||||||
|
this.computeMenuPosition();
|
||||||
|
this.focusSearchField();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
openFolderInSidebar(): void {
|
||||||
|
this.openFolderRequested.emit();
|
||||||
|
this.closeMenu();
|
||||||
|
}
|
||||||
|
|
||||||
|
goToRoot(): void {
|
||||||
|
this.breadcrumb.set([]);
|
||||||
|
this.currentLevelFolders.set(this.rootFolders());
|
||||||
|
}
|
||||||
|
|
||||||
|
navigateInto(folder: FolderEntry, event: Event): void {
|
||||||
|
event.stopPropagation();
|
||||||
|
this.breadcrumb.update(prev => [...prev, folder]);
|
||||||
|
this.currentLevelFolders.set(folder.children ?? []);
|
||||||
|
}
|
||||||
|
|
||||||
|
navigateTo(level: number): void {
|
||||||
|
if (level <= 0) {
|
||||||
|
this.goToRoot();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const trail = this.breadcrumb().slice(0, level);
|
||||||
|
this.breadcrumb.set(trail);
|
||||||
|
const last = trail[trail.length - 1];
|
||||||
|
this.currentLevelFolders.set(last?.children ?? []);
|
||||||
|
}
|
||||||
|
|
||||||
|
onSearchChange(value: string): void {
|
||||||
|
this.searchQuery.set(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
clearSearch(): void {
|
||||||
|
this.searchQuery.set('');
|
||||||
|
queueMicrotask(() => this.focusSearchField());
|
||||||
|
}
|
||||||
|
|
||||||
|
async onFolderSelected(folder: FolderEntry, event?: Event): Promise<void> {
|
||||||
|
event?.stopPropagation();
|
||||||
|
await this.performMove(folder.path);
|
||||||
|
}
|
||||||
|
|
||||||
|
async onSearchResultSelected(result: FlattenedFolder): Promise<void> {
|
||||||
|
await this.performMove(result.path);
|
||||||
|
}
|
||||||
|
|
||||||
|
moveToCurrentFolder(): Promise<void> {
|
||||||
|
return this.performMove(this.activeFolderPath());
|
||||||
|
}
|
||||||
|
|
||||||
|
onEscapeKey(): void {
|
||||||
|
this.closeMenu();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async performMove(destinationPath: string): Promise<void> {
|
||||||
|
if (!this.notePath) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const target = this.normalizeFolderPath(destinationPath);
|
||||||
|
const current = this.currentFolderPath();
|
||||||
|
|
||||||
|
if (target === current || this.processingPath()) {
|
||||||
|
this.closeMenu();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.processingPath.set(target || '__root__');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { newPath } = await this.vault.moveNoteToFolder(this.notePath, target);
|
||||||
|
this.toast.success('Note moved successfully');
|
||||||
|
|
||||||
|
const { prefix } = splitPathKeepFilename(newPath);
|
||||||
|
this.currentFolderPath.set(this.normalizeFolderPath(prefix));
|
||||||
|
this.noteMoved.emit(newPath);
|
||||||
|
this.searchQuery.set('');
|
||||||
|
this.closeMenu();
|
||||||
|
} catch (error: unknown) {
|
||||||
|
const message = error instanceof Error ? error.message : 'Failed to move note';
|
||||||
|
this.toast.error(message);
|
||||||
|
} finally {
|
||||||
|
this.processingPath.set(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildFolderSources(nodes: VaultNode[]): void {
|
||||||
|
const folders = this.mapFolders(nodes);
|
||||||
|
this.rootFolders.set(folders);
|
||||||
|
const flattened: FlattenedFolder[] = [{ name: 'All my folders', path: '', breadcrumb: [] }];
|
||||||
|
this.collectFolders(folders, [], flattened);
|
||||||
|
this.flattenedFolders.set(flattened);
|
||||||
|
this.loading.set(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
private mapFolders(nodes: VaultNode[] | undefined): FolderEntry[] {
|
||||||
|
if (!nodes?.length) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const folders: FolderEntry[] = [];
|
||||||
|
for (const node of nodes) {
|
||||||
|
if (node.type !== 'folder') continue;
|
||||||
|
|
||||||
|
folders.push({
|
||||||
|
name: node.name,
|
||||||
|
path: this.normalizeFolderPath(node.path),
|
||||||
|
children: this.mapFolders(node.children)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return folders.sort((a, b) => a.name.localeCompare(b.name, undefined, { sensitivity: 'base' }));
|
||||||
|
}
|
||||||
|
|
||||||
|
private collectFolders(nodes: FolderEntry[], trail: string[], acc: FlattenedFolder[]): void {
|
||||||
|
for (const node of nodes) {
|
||||||
|
const breadcrumb = [...trail, node.name];
|
||||||
|
acc.push({ name: node.name, path: node.path, breadcrumb });
|
||||||
|
|
||||||
|
if (node.children?.length) {
|
||||||
|
this.collectFolders(node.children, breadcrumb, acc);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private setActiveLevel(path: string, roots: FolderEntry[]): void {
|
||||||
|
if (!roots.length) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!path) {
|
||||||
|
this.breadcrumb.set([]);
|
||||||
|
this.currentLevelFolders.set(roots);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const trail = this.findTrail(roots, path);
|
||||||
|
if (!trail) {
|
||||||
|
this.breadcrumb.set([]);
|
||||||
|
this.currentLevelFolders.set(roots);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.breadcrumb.set(trail);
|
||||||
|
const last = trail[trail.length - 1];
|
||||||
|
this.currentLevelFolders.set(last?.children ?? []);
|
||||||
|
}
|
||||||
|
|
||||||
|
private findTrail(nodes: FolderEntry[], target: string, trail: FolderEntry[] = []): FolderEntry[] | null {
|
||||||
|
for (const node of nodes) {
|
||||||
|
const nextTrail = [...trail, node];
|
||||||
|
if (node.path === target) {
|
||||||
|
return nextTrail;
|
||||||
|
}
|
||||||
|
if (node.children?.length) {
|
||||||
|
const found = this.findTrail(node.children, target, nextTrail);
|
||||||
|
if (found) {
|
||||||
|
return found;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private closeMenu(): void {
|
||||||
|
this.showMenu.set(false);
|
||||||
|
this.detachGlobalListeners();
|
||||||
|
this.searchQuery.set('');
|
||||||
|
this.processingPath.set(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
private focusSearchField(): void {
|
||||||
|
const el = this.searchField?.nativeElement;
|
||||||
|
if (el) {
|
||||||
|
el.focus();
|
||||||
|
el.select();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private computeMenuPosition(): void {
|
||||||
|
const t = this.trigger?.nativeElement;
|
||||||
|
if (!t) return;
|
||||||
|
const rect = t.getBoundingClientRect();
|
||||||
|
// Place menu just under the trigger, with small margin
|
||||||
|
this.menuTop.set(Math.round(rect.bottom + window.scrollY + 8));
|
||||||
|
// Try to align left edges; ensure not off-screen (simple clamp)
|
||||||
|
const left = Math.round(rect.left + window.scrollX);
|
||||||
|
this.menuLeft.set(Math.max(8, left));
|
||||||
|
}
|
||||||
|
|
||||||
|
private attachGlobalListeners(): void {
|
||||||
|
if (typeof document === 'undefined' || this.listenersAttached) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
document.addEventListener('pointerdown', this.handleOutsidePointer, true);
|
||||||
|
document.addEventListener('keydown', this.handleEscape, true);
|
||||||
|
this.listenersAttached = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private detachGlobalListeners(): void {
|
||||||
|
if (typeof document === 'undefined' || !this.listenersAttached) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
document.removeEventListener('pointerdown', this.handleOutsidePointer, true);
|
||||||
|
document.removeEventListener('keydown', this.handleEscape, true);
|
||||||
|
this.listenersAttached = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private handleOutsidePointer = (event: PointerEvent) => {
|
||||||
|
if (!this.showMenu()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!this.host.nativeElement.contains(event.target as Node)) {
|
||||||
|
this.closeMenu();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
private handleEscape = (event: KeyboardEvent) => {
|
||||||
|
if (event.key === 'Escape' && this.showMenu()) {
|
||||||
|
this.closeMenu();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
private normalizeFolderPath(value: string | null | undefined): string {
|
||||||
|
return (value ?? '')
|
||||||
|
.replace(/\\/g, '/')
|
||||||
|
.replace(/^\/+/g, '')
|
||||||
|
.replace(/\/+$/, '')
|
||||||
|
.trim();
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -30,11 +30,14 @@
|
|||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<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">
|
<div class="path-wrap flex items-center gap-2 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">
|
<app-move-note-to-folder
|
||||||
{{ pathParts.prefix }}
|
class="move-note-trigger shrink-0"
|
||||||
</span>
|
[currentPath]="pathParts.prefix"
|
||||||
<span class="path-sep" *ngIf="pathParts.prefix">/</span>
|
[notePath]="fullPath"
|
||||||
|
(noteMoved)="onNoteMoved($event)"
|
||||||
|
(openFolderRequested)="onPathClick()"
|
||||||
|
></app-move-note-to-folder>
|
||||||
|
|
||||||
<ng-container *ngIf="!isRenaming; else renameTpl">
|
<ng-container *ngIf="!isRenaming; else renameTpl">
|
||||||
<span class="path-filename whitespace-nowrap editable transition-opacity duration-150" [title]="'Renommer le fichier'" (click)="onFileNameClick($event)">
|
<span class="path-filename whitespace-nowrap editable transition-opacity duration-150" [title]="'Renommer le fichier'" (click)="onFileNameClick($event)">
|
||||||
|
|||||||
@ -10,11 +10,12 @@ import { FrontmatterPropertiesService } from '../../shared/frontmatter-propertie
|
|||||||
import { VaultService } from '../../../../../services/vault.service';
|
import { VaultService } from '../../../../../services/vault.service';
|
||||||
import { ToastService } from '../../../../shared/toast/toast.service';
|
import { ToastService } from '../../../../shared/toast/toast.service';
|
||||||
import { UrlStateService } from '../../../../services/url-state.service';
|
import { UrlStateService } from '../../../../services/url-state.service';
|
||||||
|
import { MoveNoteToFolderComponent } from '../move-note-to-folder/move-note-to-folder.component';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-note-header',
|
selector: 'app-note-header',
|
||||||
standalone: true,
|
standalone: true,
|
||||||
imports: [CommonModule, TagManagerComponent],
|
imports: [CommonModule, TagManagerComponent, MoveNoteToFolderComponent],
|
||||||
templateUrl: './note-header.component.html',
|
templateUrl: './note-header.component.html',
|
||||||
styleUrls: ['./note-header.component.scss']
|
styleUrls: ['./note-header.component.scss']
|
||||||
})
|
})
|
||||||
@ -27,6 +28,7 @@ export class NoteHeaderComponent implements AfterViewInit, OnDestroy, OnChanges
|
|||||||
@Output() copyRequested = new EventEmitter<void>();
|
@Output() copyRequested = new EventEmitter<void>();
|
||||||
@Output() tagsChange = new EventEmitter<string[]>();
|
@Output() tagsChange = new EventEmitter<string[]>();
|
||||||
@Output() tagSelected = new EventEmitter<string>();
|
@Output() tagSelected = new EventEmitter<string>();
|
||||||
|
@Output() noteMoved = new EventEmitter<string>();
|
||||||
|
|
||||||
pathParts: { prefix: string; filename: string } = { prefix: '', filename: '' };
|
pathParts: { prefix: string; filename: string } = { prefix: '', filename: '' };
|
||||||
|
|
||||||
@ -61,6 +63,22 @@ export class NoteHeaderComponent implements AfterViewInit, OnDestroy, OnChanges
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
onNoteMoved(newPath: string): void {
|
||||||
|
if (!newPath) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.fullPath = newPath;
|
||||||
|
this.pathParts = splitPathKeepFilename(newPath);
|
||||||
|
this.noteMoved.emit(newPath);
|
||||||
|
this.urlState.openNote(newPath, { force: true });
|
||||||
|
|
||||||
|
queueMicrotask(() => {
|
||||||
|
this.applyProgressiveCollapse();
|
||||||
|
this.fitPath();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
ngAfterViewInit(): void {
|
ngAfterViewInit(): void {
|
||||||
this.pathParts = splitPathKeepFilename(this.fullPath);
|
this.pathParts = splitPathKeepFilename(this.fullPath);
|
||||||
|
|
||||||
|
|||||||
@ -60,6 +60,7 @@ export interface WikiLinkActivation {
|
|||||||
[tags]="note.tags ?? []"
|
[tags]="note.tags ?? []"
|
||||||
(copyRequested)="copyPath()"
|
(copyRequested)="copyPath()"
|
||||||
(openDirectory)="directoryClicked.emit(getDirectoryFromPath(note.filePath))"
|
(openDirectory)="directoryClicked.emit(getDirectoryFromPath(note.filePath))"
|
||||||
|
(noteMoved)="onNoteMoved($event)"
|
||||||
(tagsChange)="onTagsChange($event)"
|
(tagsChange)="onTagsChange($event)"
|
||||||
(tagSelected)="tagClicked.emit($event)"
|
(tagSelected)="tagClicked.emit($event)"
|
||||||
></app-note-header>
|
></app-note-header>
|
||||||
@ -479,6 +480,21 @@ export class NoteViewerComponent implements OnDestroy {
|
|||||||
// Pas besoin de sauvegarder ici, c'est déjà fait par TagsEditorComponent
|
// Pas besoin de sauvegarder ici, c'est déjà fait par TagsEditorComponent
|
||||||
}
|
}
|
||||||
|
|
||||||
|
onNoteMoved(newPath: string): void {
|
||||||
|
const currentNote = this.note();
|
||||||
|
if (!currentNote || !newPath) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
currentNote.filePath = newPath;
|
||||||
|
currentNote.originalPath = newPath.replace(/\\/g, '/').replace(/\.md$/i, '');
|
||||||
|
|
||||||
|
const newFolder = this.getDirectoryFromPath(newPath);
|
||||||
|
if (newFolder) {
|
||||||
|
this.directoryClicked.emit(newFolder);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async copyPath(): Promise<void> {
|
async copyPath(): Promise<void> {
|
||||||
const path = this.note()?.filePath ?? '';
|
const path = this.note()?.filePath ?? '';
|
||||||
try {
|
try {
|
||||||
|
|||||||
@ -230,6 +230,42 @@ export class VaultService implements OnDestroy {
|
|||||||
return { newPath: String(data.newPath || ''), fileName: String(data.fileName || '') };
|
return { newPath: String(data.newPath || ''), fileName: String(data.fileName || '') };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Move a markdown file to a different folder within the vault. */
|
||||||
|
async moveNoteToFolder(notePath: string, newFolderPath: string): Promise<{ oldPath: string; newPath: string }> {
|
||||||
|
const payload = {
|
||||||
|
notePath,
|
||||||
|
newFolderPath
|
||||||
|
};
|
||||||
|
|
||||||
|
const res = await fetch('/api/vault/notes/move', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(payload)
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
let reason = res.statusText;
|
||||||
|
try {
|
||||||
|
const errorBody = await res.json();
|
||||||
|
reason = errorBody?.error || reason;
|
||||||
|
} catch {
|
||||||
|
// ignore parsing error, keep default status text
|
||||||
|
}
|
||||||
|
throw new Error(`Failed to move note: ${reason}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await res.json();
|
||||||
|
|
||||||
|
// Refresh local caches to reflect the new structure
|
||||||
|
this.refresh();
|
||||||
|
this.loadFastFileTree(true);
|
||||||
|
|
||||||
|
const oldPath = String(data.oldPath ?? notePath ?? '');
|
||||||
|
const newPath = String(data.newPath ?? notePath ?? '');
|
||||||
|
|
||||||
|
return { oldPath, newPath };
|
||||||
|
}
|
||||||
|
|
||||||
getNoteById(id: string): Note | undefined {
|
getNoteById(id: string): Note | undefined {
|
||||||
return this.notesMap().get(id);
|
return this.notesMap().get(id);
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user