```
feat: add file block with drag-and-drop support - Implemented file upload block with preview support for images, videos, PDFs, text, code, and DOCX files - Added drag-and-drop directive for files with visual drop indicators in root and column contexts - Created file icon component with FontAwesome integration for 50+ file type icons ```
This commit is contained in:
parent
03857f15ff
commit
85d021b154
15
e2e/file-block.spec.ts
Normal file
15
e2e/file-block.spec.ts
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
import { test, expect } from '@playwright/test';
|
||||||
|
|
||||||
|
// Basic smoke test to ensure the editor page loads and the File tile exists
|
||||||
|
// Note: Full OS file picker automation is not performed here; this verifies UI wiring only.
|
||||||
|
|
||||||
|
test.describe('File Block - Smoke', () => {
|
||||||
|
test('palette opens and shows File item', async ({ page }) => {
|
||||||
|
await page.goto('http://localhost:4200/tests/nimbus-editor');
|
||||||
|
// Open palette via slash keyboard would be flaky; instead click menu button present in header
|
||||||
|
await page.keyboard.press('/');
|
||||||
|
await page.waitForTimeout(300);
|
||||||
|
const fileItem = page.getByText('File', { exact: true });
|
||||||
|
await expect(fileItem).toBeVisible();
|
||||||
|
});
|
||||||
|
});
|
||||||
858
package-lock.json
generated
858
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -62,6 +62,9 @@
|
|||||||
"@codemirror/view": "^6.38.6",
|
"@codemirror/view": "^6.38.6",
|
||||||
"@excalidraw/excalidraw": "^0.17.0",
|
"@excalidraw/excalidraw": "^0.17.0",
|
||||||
"@excalidraw/utils": "^0.1.0",
|
"@excalidraw/utils": "^0.1.0",
|
||||||
|
"@fortawesome/angular-fontawesome": "^3.0.0",
|
||||||
|
"@fortawesome/free-brands-svg-icons": "^7.1.0",
|
||||||
|
"@fortawesome/free-solid-svg-icons": "^7.1.0",
|
||||||
"@lezer/highlight": "^1.2.2",
|
"@lezer/highlight": "^1.2.2",
|
||||||
"@types/markdown-it": "^14.0.1",
|
"@types/markdown-it": "^14.0.1",
|
||||||
"angular-calendar": "^0.32.0",
|
"angular-calendar": "^0.32.0",
|
||||||
@ -103,7 +106,7 @@
|
|||||||
"zod": "^3.23.8"
|
"zod": "^3.23.8"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@angular-devkit/build-angular": "20.3.2",
|
"@angular-devkit/build-angular": "^20.3.10",
|
||||||
"@playwright/test": "^1.55.1",
|
"@playwright/test": "^1.55.1",
|
||||||
"@tailwindcss/forms": "^0.5.9",
|
"@tailwindcss/forms": "^0.5.9",
|
||||||
"@tailwindcss/typography": "^0.5.15",
|
"@tailwindcss/typography": "^0.5.15",
|
||||||
|
|||||||
159
src/app/blocks/file/directives/drag-drop-files.directive.ts
Normal file
159
src/app/blocks/file/directives/drag-drop-files.directive.ts
Normal file
@ -0,0 +1,159 @@
|
|||||||
|
import { Directive, ElementRef, EventEmitter, Inject, Input, NgZone, OnDestroy, OnInit, Output } from '@angular/core';
|
||||||
|
import { DOCUMENT } from '@angular/common';
|
||||||
|
import { BlockInsertionService } from '../services/block-insertion.service';
|
||||||
|
import { DropTarget } from '../models/file-models';
|
||||||
|
import { DragDropService } from '../../../editor/services/drag-drop.service';
|
||||||
|
|
||||||
|
@Directive({
|
||||||
|
selector: '[appDragDropFiles]',
|
||||||
|
standalone: true
|
||||||
|
})
|
||||||
|
export class DragDropFilesDirective implements OnInit, OnDestroy {
|
||||||
|
@Input('appDragDropFiles') context: 'root' | { type: 'columns'; columnsBlockId: string } = 'root';
|
||||||
|
@Output() filesDropped = new EventEmitter<void>();
|
||||||
|
|
||||||
|
private enterCounter = 0;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private el: ElementRef<HTMLElement>,
|
||||||
|
private zone: NgZone,
|
||||||
|
private inserter: BlockInsertionService,
|
||||||
|
private dragDrop: DragDropService,
|
||||||
|
@Inject(DOCUMENT) private document: Document
|
||||||
|
) {}
|
||||||
|
|
||||||
|
ngOnInit(): void {
|
||||||
|
this.zone.runOutsideAngular(() => {
|
||||||
|
const node = this.el.nativeElement;
|
||||||
|
node.addEventListener('dragover', this.onDragOver, { passive: false });
|
||||||
|
node.addEventListener('dragleave', this.onDragLeave);
|
||||||
|
node.addEventListener('drop', this.onDrop, { passive: false });
|
||||||
|
node.addEventListener('dragenter', this.onDragEnter);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
ngOnDestroy(): void {
|
||||||
|
const node = this.el.nativeElement;
|
||||||
|
node.removeEventListener('dragover', this.onDragOver);
|
||||||
|
node.removeEventListener('dragleave', this.onDragLeave);
|
||||||
|
node.removeEventListener('drop', this.onDrop);
|
||||||
|
node.removeEventListener('dragenter', this.onDragEnter);
|
||||||
|
}
|
||||||
|
|
||||||
|
private onDragEnter = (ev: DragEvent) => {
|
||||||
|
if (!ev.dataTransfer) return;
|
||||||
|
const hasFiles = Array.from(ev.dataTransfer.items || []).some(i => i.kind === 'file');
|
||||||
|
if (!hasFiles) return;
|
||||||
|
this.enterCounter++;
|
||||||
|
try { (this.dragDrop as any).dragging.set(true); } catch {}
|
||||||
|
};
|
||||||
|
|
||||||
|
private onDragOver = (ev: DragEvent) => {
|
||||||
|
if (!ev.dataTransfer) return;
|
||||||
|
const hasFiles = Array.from(ev.dataTransfer.items || []).some(i => i.kind === 'file');
|
||||||
|
if (!hasFiles) return;
|
||||||
|
ev.preventDefault();
|
||||||
|
ev.dataTransfer.dropEffect = 'copy';
|
||||||
|
|
||||||
|
const target = this.computeTarget(ev);
|
||||||
|
if (target) {
|
||||||
|
// Use the shared indicator for visuals
|
||||||
|
const ind = this.computeIndicator(ev, target);
|
||||||
|
this.dragDrop.setIndicator(ind);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
private onDragLeave = (_ev: DragEvent) => {
|
||||||
|
this.enterCounter = Math.max(0, this.enterCounter - 1);
|
||||||
|
if (this.enterCounter === 0) {
|
||||||
|
this.dragDrop.setIndicator(null);
|
||||||
|
try { (this.dragDrop as any).dragging.set(false); } catch {}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
private onDrop = async (ev: DragEvent) => {
|
||||||
|
try {
|
||||||
|
if (!ev.dataTransfer) return;
|
||||||
|
const files = Array.from(ev.dataTransfer.files || []);
|
||||||
|
if (!files.length) return;
|
||||||
|
ev.preventDefault();
|
||||||
|
|
||||||
|
const target = this.computeTarget(ev);
|
||||||
|
this.dragDrop.setIndicator(null);
|
||||||
|
this.enterCounter = 0;
|
||||||
|
if (!target) return;
|
||||||
|
|
||||||
|
await this.inserter.createFromFiles(files, target);
|
||||||
|
this.zone.run(() => this.filesDropped.emit());
|
||||||
|
try { (this.dragDrop as any).dragging.set(false); } catch {}
|
||||||
|
} catch {}
|
||||||
|
};
|
||||||
|
|
||||||
|
private computeTarget(ev: DragEvent): DropTarget | number | null {
|
||||||
|
const clientY = ev.clientY;
|
||||||
|
const clientX = ev.clientX;
|
||||||
|
const container = this.el.nativeElement;
|
||||||
|
const containerRect = container.getBoundingClientRect();
|
||||||
|
|
||||||
|
// Column context
|
||||||
|
if (this.context !== 'root' && this.context.type === 'columns') {
|
||||||
|
const el = this.document.elementFromPoint(clientX, clientY) as HTMLElement | null;
|
||||||
|
const colEl = el?.closest('[data-column-index]') as HTMLElement | null;
|
||||||
|
if (!colEl) return null;
|
||||||
|
const columnIndex = parseInt(colEl.getAttribute('data-column-index') || '0');
|
||||||
|
const blocks = Array.from(colEl.querySelectorAll<HTMLElement>('[data-block-id]'));
|
||||||
|
|
||||||
|
let index = blocks.length;
|
||||||
|
for (let i = 0; i < blocks.length; i++) {
|
||||||
|
const r = blocks[i].getBoundingClientRect();
|
||||||
|
const zone = r.top + r.height / 2;
|
||||||
|
if (clientY <= zone) { index = i; break; }
|
||||||
|
}
|
||||||
|
|
||||||
|
return { type: 'column', ownerColumnsId: this.context.columnsBlockId, columnId: colEl.getAttribute('data-column-id') || '', columnIndex, index };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Root context: compute index among .block-wrapper siblings
|
||||||
|
const nodes = Array.from(container.querySelectorAll<HTMLElement>('.block-wrapper'));
|
||||||
|
if (!nodes.length) return 0;
|
||||||
|
|
||||||
|
let index = nodes.length;
|
||||||
|
for (let i = 0; i < nodes.length; i++) {
|
||||||
|
const r = nodes[i].getBoundingClientRect();
|
||||||
|
const zone = r.top + r.height / 2;
|
||||||
|
if (clientY <= zone) { index = i; break; }
|
||||||
|
}
|
||||||
|
return index;
|
||||||
|
}
|
||||||
|
|
||||||
|
private computeIndicator(ev: DragEvent, target: DropTarget | number) {
|
||||||
|
const container = this.el.nativeElement;
|
||||||
|
const rect = container.getBoundingClientRect();
|
||||||
|
|
||||||
|
if (typeof target === 'number') {
|
||||||
|
const nodes = Array.from(container.querySelectorAll<HTMLElement>('.block-wrapper'));
|
||||||
|
const clampIndex = Math.max(0, Math.min(target, nodes.length));
|
||||||
|
let top = rect.top;
|
||||||
|
if (nodes.length > 0) {
|
||||||
|
if (clampIndex === 0) top = nodes[0].getBoundingClientRect().top;
|
||||||
|
else if (clampIndex >= nodes.length) top = nodes[nodes.length - 1].getBoundingClientRect().bottom;
|
||||||
|
else top = nodes[clampIndex].getBoundingClientRect().top;
|
||||||
|
}
|
||||||
|
return { top: top - rect.top, left: 0, width: rect.width, mode: 'horizontal' as const };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Column indicator: vertical line at column boundary is complex; draw horizontal between items inside column
|
||||||
|
const el = this.document.elementFromPoint(ev.clientX, ev.clientY) as HTMLElement | null;
|
||||||
|
const colEl = el?.closest('[data-column-index]') as HTMLElement | null;
|
||||||
|
const list = Array.from(colEl?.querySelectorAll<HTMLElement>('[data-block-id]') || []);
|
||||||
|
let top = rect.top;
|
||||||
|
if (list.length > 0) {
|
||||||
|
const idx = Math.max(0, Math.min(target.index, list.length));
|
||||||
|
if (idx === 0) top = list[0].getBoundingClientRect().top;
|
||||||
|
else if (idx >= list.length) top = list[list.length - 1].getBoundingClientRect().bottom;
|
||||||
|
else top = list[idx].getBoundingClientRect().top;
|
||||||
|
}
|
||||||
|
const colRect = colEl?.getBoundingClientRect() || rect;
|
||||||
|
return { top: top - rect.top, left: (colRect.left - rect.left), width: colRect.width, mode: 'horizontal' as const };
|
||||||
|
}
|
||||||
|
}
|
||||||
84
src/app/blocks/file/file-preview.component.ts
Normal file
84
src/app/blocks/file/file-preview.component.ts
Normal file
@ -0,0 +1,84 @@
|
|||||||
|
import { Component, Input, ViewChild, ElementRef, AfterViewInit, OnDestroy, signal, computed } from '@angular/core';
|
||||||
|
import { CommonModule } from '@angular/common';
|
||||||
|
import { FileMeta } from './models/file-models';
|
||||||
|
import { PreviewImageComponent } from './viewers/preview-image.component';
|
||||||
|
import { PreviewVideoComponent } from './viewers/preview-video.component';
|
||||||
|
import { PreviewTextComponent } from './viewers/preview-text.component';
|
||||||
|
import { PreviewCodeComponent } from './viewers/preview-code.component';
|
||||||
|
import { PreviewDocxComponent } from './viewers/preview-docx.component';
|
||||||
|
import { PdfViewerComponent } from '../../features/note-view/components/pdf-viewer/pdf-viewer.component';
|
||||||
|
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-file-preview',
|
||||||
|
standalone: true,
|
||||||
|
imports: [CommonModule, PreviewImageComponent, PreviewVideoComponent, PreviewTextComponent, PreviewCodeComponent, PreviewDocxComponent, PdfViewerComponent],
|
||||||
|
template: `
|
||||||
|
<div #container class="w-full">
|
||||||
|
@if (shouldRender()) {
|
||||||
|
@switch (kind()) {
|
||||||
|
@case ('image') { <app-preview-image [url]="meta.url" [width]="width()" /> }
|
||||||
|
@case ('video') { <app-preview-video [url]="meta.url" [width]="width()" /> }
|
||||||
|
@case ('pdf') { <app-pdf-viewer [src]="meta.url" [path]="meta.name" /> }
|
||||||
|
@case ('text') { <app-preview-text [url]="meta.url" [ext]="meta.ext" [width]="width()" /> }
|
||||||
|
@case ('code') { <app-preview-code [url]="meta.url" [ext]="meta.ext" [width]="width()" /> }
|
||||||
|
@case ('docx') { <app-preview-docx [url]="meta.url" /> }
|
||||||
|
@default {
|
||||||
|
<div class="text-sm text-gray-400 py-6">
|
||||||
|
Preview non disponible pour ce type. <a [href]="meta.url" download class="underline">Télécharger</a>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
`
|
||||||
|
})
|
||||||
|
export class FilePreviewComponent implements AfterViewInit, OnDestroy {
|
||||||
|
@Input({ required: true }) meta!: FileMeta;
|
||||||
|
@Input() set expanded(v: boolean) { this.expandedSig.set(!!v); }
|
||||||
|
|
||||||
|
@ViewChild('container', { static: true }) containerRef!: ElementRef<HTMLDivElement>;
|
||||||
|
|
||||||
|
width = signal(0);
|
||||||
|
private observer?: ResizeObserver;
|
||||||
|
// Intersection observer not required since we render based on expanded
|
||||||
|
private expandedSig = signal(false);
|
||||||
|
readonly shouldRender = computed(() => this.expandedSig());
|
||||||
|
kind = signal<'image'|'video'|'pdf'|'text'|'code'|'docx'|'other'>('other');
|
||||||
|
|
||||||
|
ngAfterViewInit(): void {
|
||||||
|
try {
|
||||||
|
this.observer = new ResizeObserver((entries) => {
|
||||||
|
const r = entries[0].contentRect;
|
||||||
|
this.width.set(Math.floor(r.width));
|
||||||
|
});
|
||||||
|
this.observer.observe(this.containerRef.nativeElement);
|
||||||
|
} catch {}
|
||||||
|
|
||||||
|
// No intersection observer: render is driven solely by expanded state
|
||||||
|
|
||||||
|
// derive kind once on init (works for legacy blocks lacking meta.kind)
|
||||||
|
try {
|
||||||
|
const k = (this.meta as any)?.kind as string | undefined;
|
||||||
|
if (k) {
|
||||||
|
this.kind.set(k as any);
|
||||||
|
} else {
|
||||||
|
const ext = (this.meta as any)?.ext?.toLowerCase?.() || '';
|
||||||
|
const map = (e: string): any => {
|
||||||
|
if (['png','jpg','jpeg','gif','webp','bmp','svg'].includes(e)) return 'image';
|
||||||
|
if (['mp4','webm','mov','m4v','ogg'].includes(e)) return 'video';
|
||||||
|
if (e === 'pdf') return 'pdf';
|
||||||
|
if (['txt','md','log','csv'].includes(e)) return 'text';
|
||||||
|
if (['js','ts','json','css','scss','html','xml','yml','yaml','py','java','cs','cpp','c','go','rs','rb','php','sh','bat','ps1'].includes(e)) return 'code';
|
||||||
|
if (e === 'docx') return 'docx';
|
||||||
|
return 'other';
|
||||||
|
};
|
||||||
|
this.kind.set(map(ext));
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
ngOnDestroy(): void {
|
||||||
|
try { this.observer?.disconnect(); } catch {}
|
||||||
|
}
|
||||||
|
}
|
||||||
39
src/app/blocks/file/models/file-models.ts
Normal file
39
src/app/blocks/file/models/file-models.ts
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
export type FileKind = 'image' | 'video' | 'pdf' | 'text' | 'code' | 'docx' | 'other';
|
||||||
|
|
||||||
|
export interface FileMeta {
|
||||||
|
id: string; // uuid
|
||||||
|
name: string;
|
||||||
|
size: number;
|
||||||
|
mime: string;
|
||||||
|
ext: string;
|
||||||
|
kind: FileKind;
|
||||||
|
createdAt: number;
|
||||||
|
url: string; // blob URL for local preview or remote URL
|
||||||
|
hash?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FileBlockUIState {
|
||||||
|
expanded: boolean;
|
||||||
|
layout: 'list' | 'grid';
|
||||||
|
widthPx?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FileBlockProps {
|
||||||
|
meta: FileMeta;
|
||||||
|
ui: FileBlockUIState;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DropTargetRoot {
|
||||||
|
type: 'root';
|
||||||
|
index: number; // position in main block list
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DropTargetColumn {
|
||||||
|
type: 'column';
|
||||||
|
ownerColumnsId: string; // block id of the Columns block
|
||||||
|
columnId: string; // column id within ColumnsProps
|
||||||
|
columnIndex: number; // runtime index for convenience
|
||||||
|
index: number; // position inside the column blocks
|
||||||
|
}
|
||||||
|
|
||||||
|
export type DropTarget = DropTargetRoot | DropTargetColumn;
|
||||||
144
src/app/blocks/file/pipes/file-icon.pipe.ts
Normal file
144
src/app/blocks/file/pipes/file-icon.pipe.ts
Normal file
@ -0,0 +1,144 @@
|
|||||||
|
// file-icon.component.ts
|
||||||
|
import { Component, Input } from '@angular/core';
|
||||||
|
import { FontAwesomeModule, IconDefinition } from '@fortawesome/angular-fontawesome';
|
||||||
|
import {
|
||||||
|
faFilePdf, faFileWord, faFileExcel, faFilePowerpoint,
|
||||||
|
faFileImage, faFileVideo, faFileAudio, faFileArchive,
|
||||||
|
faFileCode, faFileLines, faFolder, faPaperclip,
|
||||||
|
faTerminal, faGem, faDatabase
|
||||||
|
} from '@fortawesome/free-solid-svg-icons';
|
||||||
|
import {
|
||||||
|
faJs, faHtml5, faCss3Alt, faPython, faJava,
|
||||||
|
faDocker, faReact, faVuejs, faSass, faMarkdown, faPhp,
|
||||||
|
faWindows
|
||||||
|
} from '@fortawesome/free-brands-svg-icons';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-file-icon',
|
||||||
|
standalone: true,
|
||||||
|
imports: [FontAwesomeModule],
|
||||||
|
template: `<fa-icon [icon]="icon" [style.color]="color" size="lg"></fa-icon>`,
|
||||||
|
styles: [`:host { display: inline-flex; align-items: center; }`]
|
||||||
|
})
|
||||||
|
export class FileIconComponent {
|
||||||
|
@Input() set ext(value: string | undefined) {
|
||||||
|
this.updateIcon(value || '', '');
|
||||||
|
}
|
||||||
|
@Input() set kind(value: string | undefined) {
|
||||||
|
this.updateIcon('', value || '');
|
||||||
|
}
|
||||||
|
|
||||||
|
icon: IconDefinition = faPaperclip;
|
||||||
|
color: string = '#6c757d';
|
||||||
|
|
||||||
|
private updateIcon(ext: string, kind: string) {
|
||||||
|
const e = ext.toLowerCase();
|
||||||
|
const iconMap: Record<string, { icon: IconDefinition; color: string }> = {
|
||||||
|
// Extensions populaires
|
||||||
|
pdf: { icon: faFilePdf, color: '#dc3545' },
|
||||||
|
doc: { icon: faFileWord, color: '#0d6efd' },
|
||||||
|
docx: { icon: faFileWord, color: '#0d6efd' },
|
||||||
|
xls: { icon: faFileExcel, color: '#198754' },
|
||||||
|
xlsx: { icon: faFileExcel, color: '#198754' },
|
||||||
|
ppt: { icon: faFilePowerpoint, color: '#fd7e14' },
|
||||||
|
pptx: { icon: faFilePowerpoint, color: '#fd7e14' },
|
||||||
|
txt: { icon: faFileLines, color: '#6c757d' },
|
||||||
|
md: { icon: faMarkdown, color: '#000000' },
|
||||||
|
csv: { icon: faFileExcel, color: '#198754' },
|
||||||
|
|
||||||
|
// Images
|
||||||
|
jpg: { icon: faFileImage, color: '#20c997' },
|
||||||
|
jpeg: { icon: faFileImage, color: '#20c997' },
|
||||||
|
png: { icon: faFileImage, color: '#20c997' },
|
||||||
|
gif: { icon: faFileImage, color: '#20c997' },
|
||||||
|
svg: { icon: faFileImage, color: '#20c997' },
|
||||||
|
webp: { icon: faFileImage, color: '#20c997' },
|
||||||
|
|
||||||
|
// Vidéos
|
||||||
|
mp4: { icon: faFileVideo, color: '#6f42c1' },
|
||||||
|
avi: { icon: faFileVideo, color: '#6f42c1' },
|
||||||
|
mov: { icon: faFileVideo, color: '#6f42c1' },
|
||||||
|
|
||||||
|
// Code
|
||||||
|
js: { icon: faJs, color: '#f7df1e' },
|
||||||
|
mjs: { icon: faJs, color: '#f7df1e' },
|
||||||
|
ts: { icon: faJs, color: '#3178c6' },
|
||||||
|
html: { icon: faHtml5, color: '#e34c26' },
|
||||||
|
htm: { icon: faHtml5, color: '#e34c26' },
|
||||||
|
css: { icon: faCss3Alt, color: '#1572b6' },
|
||||||
|
scss: { icon: faSass, color: '#cc6699' },
|
||||||
|
sass: { icon: faSass, color: '#cc6699' },
|
||||||
|
less: { icon: faCss3Alt, color: '#1d365d' },
|
||||||
|
php: { icon: faPhp, color: '#777bb4' },
|
||||||
|
py: { icon: faPython, color: '#3776ab' },
|
||||||
|
java: { icon: faJava, color: '#ed8b00' },
|
||||||
|
class: { icon: faJava, color: '#ed8b00' },
|
||||||
|
jar: { icon: faJava, color: '#ed8b00' },
|
||||||
|
c: { icon: faFileCode, color: '#a8b9cc' },
|
||||||
|
cpp: { icon: faFileCode, color: '#00599c' },
|
||||||
|
cs: { icon: faFileCode, color: '#239120' },
|
||||||
|
h: { icon: faFileCode, color: '#a8b9cc' },
|
||||||
|
go: { icon: faFileCode, color: '#00add8' },
|
||||||
|
rs: { icon: faFileCode, color: '#dea584' },
|
||||||
|
rb: { icon: faGem, color: '#cc342d' },
|
||||||
|
swift: { icon: faFileCode, color: '#fa7343' },
|
||||||
|
kt: { icon: faFileCode, color: '#7f52ff' },
|
||||||
|
vue: { icon: faVuejs, color: '#4fc08d' },
|
||||||
|
jsx: { icon: faReact, color: '#61dafb' },
|
||||||
|
tsx: { icon: faReact, color: '#61dafb' },
|
||||||
|
sql: { icon: faDatabase, color: '#336791' },
|
||||||
|
json: { icon: faFileCode, color: '#000000' },
|
||||||
|
xml: { icon: faFileCode, color: '#000000' },
|
||||||
|
yml: { icon: faFileCode, color: '#ff0000' },
|
||||||
|
yaml: { icon: faFileCode, color: '#ff0000' },
|
||||||
|
|
||||||
|
// Scripts
|
||||||
|
sh: { icon: faTerminal, color: '#000000' },
|
||||||
|
bash: { icon: faTerminal, color: '#000000' },
|
||||||
|
zsh: { icon: faTerminal, color: '#000000' },
|
||||||
|
ps1: { icon: faTerminal, color: '#000000' },
|
||||||
|
bat: { icon: faWindows, color: '#0078d4' },
|
||||||
|
|
||||||
|
// Archives
|
||||||
|
zip: { icon: faFileArchive, color: '#ffc107' },
|
||||||
|
rar: { icon: faFileArchive, color: '#ffc107' },
|
||||||
|
'7z': { icon: faFileArchive, color: '#ffc107' },
|
||||||
|
tar: { icon: faFileArchive, color: '#ffc107' },
|
||||||
|
gz: { icon: faFileArchive, color: '#ffc107' },
|
||||||
|
|
||||||
|
// Config
|
||||||
|
ini: { icon: faFileLines, color: '#6c757d' },
|
||||||
|
cfg: { icon: faFileLines, color: '#6c757d' },
|
||||||
|
conf: { icon: faFileLines, color: '#6c757d' },
|
||||||
|
env: { icon: faFileLines, color: '#6c757d' },
|
||||||
|
lock: { icon: faFileLines, color: '#6c757d' },
|
||||||
|
|
||||||
|
// Docker
|
||||||
|
dockerfile: { icon: faDocker, color: '#2496ed' },
|
||||||
|
};
|
||||||
|
|
||||||
|
// Priorité à l'extension
|
||||||
|
if (iconMap[e]) {
|
||||||
|
this.icon = iconMap[e].icon;
|
||||||
|
this.color = iconMap[e].color;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback sur le type
|
||||||
|
const kindMap: Record<string, { icon: IconDefinition; color: string }> = {
|
||||||
|
image: { icon: faFileImage, color: '#20c997' },
|
||||||
|
video: { icon: faFileVideo, color: '#6f42c1' },
|
||||||
|
audio: { icon: faFileAudio, color: '#6f42c1' },
|
||||||
|
pdf: { icon: faFilePdf, color: '#dc3545' },
|
||||||
|
text: { icon: faFileLines, color: '#6c757d' },
|
||||||
|
code: { icon: faFileCode, color: '#6c757d' },
|
||||||
|
doc: { icon: faFileWord, color: '#0d6efd' },
|
||||||
|
archive: { icon: faFileArchive, color: '#ffc107' },
|
||||||
|
folder: { icon: faFolder, color: '#ffc107' },
|
||||||
|
};
|
||||||
|
|
||||||
|
const kindIcon = kindMap[kind] || { icon: faPaperclip, color: '#6c757d' };
|
||||||
|
this.icon = kindIcon.icon;
|
||||||
|
this.color = kindIcon.color;
|
||||||
|
}
|
||||||
|
}
|
||||||
16
src/app/blocks/file/pipes/file-size.pipe.ts
Normal file
16
src/app/blocks/file/pipes/file-size.pipe.ts
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
import { Pipe, PipeTransform } from '@angular/core';
|
||||||
|
|
||||||
|
@Pipe({ name: 'fileSize', standalone: true })
|
||||||
|
export class FileSizePipe implements PipeTransform {
|
||||||
|
transform(bytes?: number | null): string {
|
||||||
|
if (bytes == null || isNaN(bytes as any)) return '';
|
||||||
|
const b = Number(bytes);
|
||||||
|
if (b < 1024) return `${b} B`;
|
||||||
|
const kb = b / 1024;
|
||||||
|
if (kb < 1024) return `${kb.toFixed(kb < 10 ? 1 : 0)} kB`;
|
||||||
|
const mb = kb / 1024;
|
||||||
|
if (mb < 1024) return `${mb.toFixed(mb < 10 ? 1 : 0)} MB`;
|
||||||
|
const gb = mb / 1024;
|
||||||
|
return `${gb.toFixed(gb < 10 ? 1 : 0)} GB`;
|
||||||
|
}
|
||||||
|
}
|
||||||
128
src/app/blocks/file/services/block-insertion.service.ts
Normal file
128
src/app/blocks/file/services/block-insertion.service.ts
Normal file
@ -0,0 +1,128 @@
|
|||||||
|
import { Injectable } from '@angular/core';
|
||||||
|
import { DocumentService } from '../../../editor/services/document.service';
|
||||||
|
import { Block } from '../../../editor/core/models/block.model';
|
||||||
|
import { FileKind, FileMeta, DropTarget, DropTargetColumn } from '../models/file-models';
|
||||||
|
import { FileMimeService } from './file-mime.service';
|
||||||
|
|
||||||
|
function uuid(): string {
|
||||||
|
return Math.random().toString(36).slice(2) + Math.random().toString(36).slice(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable({ providedIn: 'root' })
|
||||||
|
export class BlockInsertionService {
|
||||||
|
private MAX_INLINE_PREVIEW_SIZE = 25 * 1024 * 1024; // 25MB default
|
||||||
|
|
||||||
|
constructor(private docs: DocumentService, private mime: FileMimeService) {}
|
||||||
|
|
||||||
|
async createFromFiles(files: File[], target: DropTarget | number): Promise<string[]> {
|
||||||
|
// Preserve order as received
|
||||||
|
const metas: FileMeta[] = await Promise.all(files.map(async (f) => {
|
||||||
|
const meta: FileMeta = {
|
||||||
|
id: uuid(),
|
||||||
|
name: f.name,
|
||||||
|
size: f.size,
|
||||||
|
mime: f.type || this.guessMimeFromExt(f.name),
|
||||||
|
ext: this.mime.getExt(f.name),
|
||||||
|
kind: this.mime.kindFromFile(f),
|
||||||
|
createdAt: Date.now(),
|
||||||
|
url: URL.createObjectURL(f)
|
||||||
|
};
|
||||||
|
return meta;
|
||||||
|
}));
|
||||||
|
|
||||||
|
const createdIds: string[] = [];
|
||||||
|
|
||||||
|
if (typeof target === 'number') {
|
||||||
|
// Root list absolute index
|
||||||
|
let insertAt = Math.max(0, Math.min(target, this.docs.blocks().length));
|
||||||
|
for (const meta of metas) {
|
||||||
|
const block = this.buildFileBlock(meta);
|
||||||
|
// Insert using afterBlockId API
|
||||||
|
const afterId = insertAt === 0 ? null : this.docs.blocks()[insertAt - 1]?.id ?? null;
|
||||||
|
this.docs.insertBlock(afterId, block);
|
||||||
|
createdIds.push(block.id);
|
||||||
|
insertAt++;
|
||||||
|
}
|
||||||
|
return createdIds;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (target.type === 'root') {
|
||||||
|
return this.createFromFiles(files, target.index);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Column target
|
||||||
|
await this.insertIntoColumn(metas, target);
|
||||||
|
return createdIds;
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildFileBlock(meta: FileMeta): Block<any> {
|
||||||
|
const ui = { expanded: false, layout: 'list' as const };
|
||||||
|
const props = { meta, ui };
|
||||||
|
return this.docs.createBlock('file', props);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async insertIntoColumn(metas: FileMeta[], t: DropTargetColumn): Promise<void> {
|
||||||
|
const doc = this.docs.doc();
|
||||||
|
const ownerIndex = doc.blocks.findIndex(b => b.id === t.ownerColumnsId && b.type === 'columns');
|
||||||
|
if (ownerIndex < 0) return;
|
||||||
|
const owner = doc.blocks[ownerIndex];
|
||||||
|
const props = JSON.parse(JSON.stringify(owner.props));
|
||||||
|
const col = props.columns?.[t.columnIndex];
|
||||||
|
if (!col) return;
|
||||||
|
|
||||||
|
let insertAt = Math.max(0, Math.min(t.index, col.blocks.length));
|
||||||
|
for (const meta of metas) {
|
||||||
|
const block = this.buildFileBlock(meta);
|
||||||
|
col.blocks.splice(insertAt, 0, block);
|
||||||
|
insertAt++;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.docs.updateBlockProps(owner.id, props);
|
||||||
|
}
|
||||||
|
|
||||||
|
togglePreview(blockId: string, singleInColumn?: { columnsId: string; columnIndex: number } | null): void {
|
||||||
|
const block = this.docs.getBlock(blockId);
|
||||||
|
if (!block) return;
|
||||||
|
const current = block.props?.ui?.expanded === true;
|
||||||
|
|
||||||
|
if (singleInColumn && singleInColumn.columnsId) {
|
||||||
|
// Collapse other file blocks in the same column
|
||||||
|
const doc = this.docs.doc();
|
||||||
|
const columnsBlock = doc.blocks.find(b => b.id === singleInColumn.columnsId && b.type === 'columns');
|
||||||
|
if (columnsBlock) {
|
||||||
|
const props = JSON.parse(JSON.stringify(columnsBlock.props));
|
||||||
|
const col = props.columns?.[singleInColumn.columnIndex];
|
||||||
|
if (col) {
|
||||||
|
col.blocks = col.blocks.map((b: Block) => {
|
||||||
|
if (b.id === blockId) return b;
|
||||||
|
if (b.type === 'file' && b.props?.ui) {
|
||||||
|
return { ...b, props: { ...b.props, ui: { ...b.props.ui, expanded: false } } };
|
||||||
|
}
|
||||||
|
return b;
|
||||||
|
});
|
||||||
|
this.docs.updateBlockProps(columnsBlock.id, props);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Toggle current block
|
||||||
|
this.docs.updateBlockProps(blockId, { ui: { ...(block as any).props?.ui, expanded: !current } });
|
||||||
|
}
|
||||||
|
|
||||||
|
private guessMimeFromExt(name: string): string {
|
||||||
|
const ext = this.mime.getExt(name);
|
||||||
|
switch (ext) {
|
||||||
|
case 'pdf': return 'application/pdf';
|
||||||
|
case 'png': return 'image/png';
|
||||||
|
case 'jpg':
|
||||||
|
case 'jpeg': return 'image/jpeg';
|
||||||
|
case 'gif': return 'image/gif';
|
||||||
|
case 'webp': return 'image/webp';
|
||||||
|
case 'mp4': return 'video/mp4';
|
||||||
|
case 'webm': return 'video/webm';
|
||||||
|
case 'txt': return 'text/plain';
|
||||||
|
case 'md': return 'text/markdown';
|
||||||
|
default: return '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
35
src/app/blocks/file/services/file-mime.service.spec.ts
Normal file
35
src/app/blocks/file/services/file-mime.service.spec.ts
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
import { FileMimeService } from './file-mime.service';
|
||||||
|
|
||||||
|
describe('FileMimeService', () => {
|
||||||
|
let service: FileMimeService;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
service = new FileMimeService();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('detects image by extension', () => {
|
||||||
|
expect(service.kindFromName('photo.PNG')).toBe('image');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('detects video by extension', () => {
|
||||||
|
expect(service.kindFromName('movie.mp4')).toBe('video');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('detects pdf by extension', () => {
|
||||||
|
expect(service.kindFromName('report.PDF')).toBe('pdf');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('detects text by extension', () => {
|
||||||
|
expect(service.kindFromName('readme.md')).toBe('text');
|
||||||
|
expect(service.kindFromName('notes.txt')).toBe('text');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('detects code by extension', () => {
|
||||||
|
expect(service.kindFromName('main.ts')).toBe('code');
|
||||||
|
expect(service.kindFromName('index.html')).toBe('code');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('defaults to other', () => {
|
||||||
|
expect(service.kindFromName('archive.zip')).toBe('other');
|
||||||
|
});
|
||||||
|
});
|
||||||
42
src/app/blocks/file/services/file-mime.service.ts
Normal file
42
src/app/blocks/file/services/file-mime.service.ts
Normal file
@ -0,0 +1,42 @@
|
|||||||
|
import { Injectable } from '@angular/core';
|
||||||
|
import { FileKind } from '../models/file-models';
|
||||||
|
|
||||||
|
@Injectable({ providedIn: 'root' })
|
||||||
|
export class FileMimeService {
|
||||||
|
kindFromName(name: string): FileKind {
|
||||||
|
const ext = this.getExt(name);
|
||||||
|
return this.kindFromExt(ext);
|
||||||
|
}
|
||||||
|
|
||||||
|
kindFromExt(ext: string): FileKind {
|
||||||
|
const e = ext.toLowerCase();
|
||||||
|
if (['png','jpg','jpeg','gif','webp','bmp','svg','heic','heif','tiff'].includes(e)) return 'image';
|
||||||
|
if (['mp4','webm','ogg','mov','mkv','avi'].includes(e)) return 'video';
|
||||||
|
if (['pdf'].includes(e)) return 'pdf';
|
||||||
|
if (['md','markdown','txt','log','rtf','csv'].includes(e)) return 'text';
|
||||||
|
if (['docx'].includes(e)) return 'docx';
|
||||||
|
if (['js','ts','tsx','jsx','py','java','cs','c','cpp','h','hpp','rs','go','rb','php','sh','bash','ps1','psm1','json','yaml','yml','toml','ini','gradle','kt','swift','scala','sql','lua','pl','perl','r','dart','s','asm','bat','cmd','makefile','dockerfile','nginx','conf','html','css','scss','less'].includes(e)) return 'code';
|
||||||
|
return 'other';
|
||||||
|
}
|
||||||
|
|
||||||
|
kindFromFile(file: File): FileKind {
|
||||||
|
const mime = (file.type || '').toLowerCase();
|
||||||
|
if (mime.startsWith('image/')) return 'image';
|
||||||
|
if (mime.startsWith('video/')) return 'video';
|
||||||
|
if (mime === 'application/pdf') return 'pdf';
|
||||||
|
if (mime.startsWith('text/')) {
|
||||||
|
const ext = this.getExt(file.name);
|
||||||
|
if (['md','markdown'].includes(ext)) return 'text';
|
||||||
|
if (this.kindFromExt(ext) === 'code') return 'code';
|
||||||
|
return 'text';
|
||||||
|
}
|
||||||
|
const ext = this.getExt(file.name);
|
||||||
|
return this.kindFromExt(ext);
|
||||||
|
}
|
||||||
|
|
||||||
|
getExt(name: string): string {
|
||||||
|
const idx = name.lastIndexOf('.');
|
||||||
|
if (idx < 0) return '';
|
||||||
|
return name.substring(idx + 1).toLowerCase();
|
||||||
|
}
|
||||||
|
}
|
||||||
37
src/app/blocks/file/services/file-picker.service.ts
Normal file
37
src/app/blocks/file/services/file-picker.service.ts
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
import { Injectable, NgZone } from '@angular/core';
|
||||||
|
|
||||||
|
export interface PickOptions {
|
||||||
|
multiple?: boolean;
|
||||||
|
accept?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable({ providedIn: 'root' })
|
||||||
|
export class FilePickerService {
|
||||||
|
private input?: HTMLInputElement;
|
||||||
|
|
||||||
|
constructor(private zone: NgZone) {}
|
||||||
|
|
||||||
|
pick(options: PickOptions = { multiple: true, accept: '*/*' }): Promise<File[]> {
|
||||||
|
return new Promise<File[]>((resolve) => {
|
||||||
|
if (!this.input) {
|
||||||
|
this.input = document.createElement('input');
|
||||||
|
this.input.type = 'file';
|
||||||
|
this.input.style.display = 'none';
|
||||||
|
document.body.appendChild(this.input);
|
||||||
|
}
|
||||||
|
this.input.multiple = !!options.multiple;
|
||||||
|
this.input.accept = options.accept ?? '*/*';
|
||||||
|
this.input.value = '';
|
||||||
|
|
||||||
|
const onChange = () => {
|
||||||
|
const files = Array.from(this.input!.files || []);
|
||||||
|
this.input!.removeEventListener('change', onChange);
|
||||||
|
// Zone reentry for Angular
|
||||||
|
this.zone.run(() => resolve(files));
|
||||||
|
};
|
||||||
|
|
||||||
|
this.input.addEventListener('change', onChange, { once: true });
|
||||||
|
this.input.click();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
97
src/app/blocks/file/viewers/preview-code.component.ts
Normal file
97
src/app/blocks/file/viewers/preview-code.component.ts
Normal file
@ -0,0 +1,97 @@
|
|||||||
|
import { Component, Input, OnInit, OnDestroy, signal } from '@angular/core';
|
||||||
|
import { CommonModule } from '@angular/common';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-preview-code',
|
||||||
|
standalone: true,
|
||||||
|
imports: [CommonModule],
|
||||||
|
template: `
|
||||||
|
<div class="w-full">
|
||||||
|
<div class="border rounded-lg bg-[--panel] text-[--fg]">
|
||||||
|
<pre class="m-0 p-3 max-h-[640px] min-h-[320px] overflow-auto font-mono text-sm"><code class="hljs" [innerHTML]="highlighted()"></code></pre>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`,
|
||||||
|
styles: [`
|
||||||
|
.hljs { display:block; white-space:pre; }
|
||||||
|
.hljs .line { display:block; position:relative; padding-left: 3.25rem; }
|
||||||
|
.hljs .line::before {
|
||||||
|
content: counter(line);
|
||||||
|
counter-increment: line;
|
||||||
|
position: absolute; left: 0; width: 2.5rem; text-align: right; color: #9ca3af; /* gray-400 */
|
||||||
|
padding-right: 0.5rem; user-select: none;
|
||||||
|
}
|
||||||
|
pre { counter-reset: line; }
|
||||||
|
/* Minimal colors for highlight.js token classes (dark theme friendly) */
|
||||||
|
.hljs-comment, .hljs-quote { color:#6b7280; }
|
||||||
|
.hljs-keyword, .hljs-selector-tag, .hljs-subst { color:#93c5fd; }
|
||||||
|
.hljs-literal, .hljs-number { color:#fca5a5; }
|
||||||
|
.hljs-string, .hljs-doctag, .hljs-regexp { color:#86efac; }
|
||||||
|
.hljs-title, .hljs-section { color:#fcd34d; }
|
||||||
|
.hljs-type, .hljs-class .hljs-title { color:#f9a8d4; }
|
||||||
|
.hljs-attribute, .hljs-name, .hljs-tag { color:#f9a8d4; }
|
||||||
|
.hljs-attr, .hljs-variable, .hljs-template-variable { color:#f472b6; }
|
||||||
|
`]
|
||||||
|
})
|
||||||
|
export class PreviewCodeComponent implements OnInit, OnDestroy {
|
||||||
|
@Input({ required: true }) url!: string;
|
||||||
|
@Input() width = 0;
|
||||||
|
@Input() ext: string = '';
|
||||||
|
|
||||||
|
highlighted = signal<string>('Loading...');
|
||||||
|
private ctrl?: AbortController;
|
||||||
|
|
||||||
|
async ngOnInit(): Promise<void> {
|
||||||
|
try {
|
||||||
|
this.ctrl = new AbortController();
|
||||||
|
const res = await fetch(this.url, { signal: this.ctrl.signal });
|
||||||
|
const text = await res.text();
|
||||||
|
const lang = this.mapExtToLanguage(this.ext);
|
||||||
|
const hljs = (await import('highlight.js')).default;
|
||||||
|
let html = '';
|
||||||
|
try {
|
||||||
|
html = lang ? hljs.highlight(text, { language: lang }).value : hljs.highlightAuto(text).value;
|
||||||
|
} catch {
|
||||||
|
html = hljs.highlightAuto(text).value;
|
||||||
|
}
|
||||||
|
const withLines = html.split('\n').map(l => `<span class="line">${l || ' '}</span>`).join('\n');
|
||||||
|
this.highlighted.set(withLines);
|
||||||
|
} catch (e) {
|
||||||
|
this.highlighted.set('Failed to load code preview.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ngOnDestroy(): void {
|
||||||
|
try { this.ctrl?.abort(); } catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
private mapExtToLanguage(ext: string): string | '' {
|
||||||
|
const e = (ext || '').toLowerCase();
|
||||||
|
switch (e) {
|
||||||
|
case 'ts': return 'typescript';
|
||||||
|
case 'js': return 'javascript';
|
||||||
|
case 'json': return 'json';
|
||||||
|
case 'py': return 'python';
|
||||||
|
case 'ps1': return 'powershell';
|
||||||
|
case 'sh': return 'bash';
|
||||||
|
case 'bash': return 'bash';
|
||||||
|
case 'html': return 'xml';
|
||||||
|
case 'xml': return 'xml';
|
||||||
|
case 'css': return 'css';
|
||||||
|
case 'scss': return 'scss';
|
||||||
|
case 'java': return 'java';
|
||||||
|
case 'cs': return 'csharp';
|
||||||
|
case 'cpp': return 'cpp';
|
||||||
|
case 'c': return 'c';
|
||||||
|
case 'go': return 'go';
|
||||||
|
case 'rs': return 'rust';
|
||||||
|
case 'rb': return 'ruby';
|
||||||
|
case 'php': return 'php';
|
||||||
|
case 'sql': return 'sql';
|
||||||
|
case 'yml':
|
||||||
|
case 'yaml': return 'yaml';
|
||||||
|
case 'md': return 'markdown';
|
||||||
|
default: return '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
21
src/app/blocks/file/viewers/preview-docx.component.ts
Normal file
21
src/app/blocks/file/viewers/preview-docx.component.ts
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
import { Component, Input } from '@angular/core';
|
||||||
|
import { CommonModule } from '@angular/common';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-preview-docx',
|
||||||
|
standalone: true,
|
||||||
|
imports: [CommonModule],
|
||||||
|
template: `
|
||||||
|
<div class="w-full py-6 text-sm text-gray-300">
|
||||||
|
<div class="rounded-lg border border-[--border] bg-[--panel] p-4">
|
||||||
|
<p class="mb-2">Preview DOCX non supporté.</p>
|
||||||
|
<a [href]="url" download class="inline-flex items-center gap-2 px-3 py-1.5 rounded bg-[--accent] text-white hover:opacity-90">
|
||||||
|
Télécharger
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`
|
||||||
|
})
|
||||||
|
export class PreviewDocxComponent {
|
||||||
|
@Input({ required: true }) url!: string;
|
||||||
|
}
|
||||||
18
src/app/blocks/file/viewers/preview-image.component.ts
Normal file
18
src/app/blocks/file/viewers/preview-image.component.ts
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
import { Component, Input } from '@angular/core';
|
||||||
|
import { CommonModule } from '@angular/common';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-preview-image',
|
||||||
|
standalone: true,
|
||||||
|
imports: [CommonModule],
|
||||||
|
template: `
|
||||||
|
<div class="relative">
|
||||||
|
<img [src]="url" [alt]="alt" class="max-w-full h-auto rounded-lg shadow-sm" />
|
||||||
|
</div>
|
||||||
|
`
|
||||||
|
})
|
||||||
|
export class PreviewImageComponent {
|
||||||
|
@Input({ required: true }) url!: string;
|
||||||
|
@Input() width = 0;
|
||||||
|
@Input() alt = '';
|
||||||
|
}
|
||||||
31
src/app/blocks/file/viewers/preview-pdf.component.ts
Normal file
31
src/app/blocks/file/viewers/preview-pdf.component.ts
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
import { Component, Input } from '@angular/core';
|
||||||
|
import { DomSanitizer, SafeResourceUrl } from '@angular/platform-browser';
|
||||||
|
import { CommonModule } from '@angular/common';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-preview-pdf',
|
||||||
|
standalone: true,
|
||||||
|
imports: [CommonModule],
|
||||||
|
template: `
|
||||||
|
<div class="w-full">
|
||||||
|
<iframe
|
||||||
|
[src]="safeUrl"
|
||||||
|
class="w-full rounded-lg bg-white"
|
||||||
|
[style.height.px]="pdfHeight()"
|
||||||
|
sandbox="allow-scripts allow-same-origin allow-downloads"
|
||||||
|
></iframe>
|
||||||
|
</div>
|
||||||
|
`
|
||||||
|
})
|
||||||
|
export class PreviewPdfComponent {
|
||||||
|
@Input({ required: true }) set url(v: string) { this.safeUrl = this.sanitizer.bypassSecurityTrustResourceUrl(v); }
|
||||||
|
@Input() width = 720;
|
||||||
|
safeUrl!: SafeResourceUrl;
|
||||||
|
|
||||||
|
constructor(private readonly sanitizer: DomSanitizer) {}
|
||||||
|
|
||||||
|
pdfHeight(): number {
|
||||||
|
const h = Math.max(360, Math.min(920, Math.floor(this.width * 1.25)));
|
||||||
|
return h;
|
||||||
|
}
|
||||||
|
}
|
||||||
64
src/app/blocks/file/viewers/preview-text.component.ts
Normal file
64
src/app/blocks/file/viewers/preview-text.component.ts
Normal file
@ -0,0 +1,64 @@
|
|||||||
|
import { Component, Input, OnInit, OnDestroy, signal } from '@angular/core';
|
||||||
|
import { CommonModule } from '@angular/common';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-preview-text',
|
||||||
|
standalone: true,
|
||||||
|
imports: [CommonModule],
|
||||||
|
template: `
|
||||||
|
<div class="w-full">
|
||||||
|
<div class="border rounded-lg bg-[--panel] text-[--fg]">
|
||||||
|
<pre class="m-0 p-3 max-h-[640px] min-h-[320px] overflow-auto font-mono text-sm"><code class="hljs" [innerHTML]="highlighted()"></code></pre>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`,
|
||||||
|
styles: [`
|
||||||
|
.hljs { display:block; white-space:pre-wrap; }
|
||||||
|
.hljs .line { display:block; position:relative; padding-left: 3.25rem; }
|
||||||
|
.hljs .line::before { content: counter(line); counter-increment: line; position:absolute; left:0; width:2.5rem; text-align:right; color:#9ca3af; padding-right:0.5rem; user-select:none; }
|
||||||
|
pre { counter-reset: line; }
|
||||||
|
.hljs-comment { color:#6b7280; }
|
||||||
|
.hljs-keyword { color:#93c5fd; }
|
||||||
|
.hljs-string { color:#86efac; }
|
||||||
|
.hljs-number { color:#fca5a5; }
|
||||||
|
.hljs-title { color:#fcd34d; }
|
||||||
|
.hljs-attr, .hljs-attribute, .hljs-built_in { color:#f9a8d4; }
|
||||||
|
`]
|
||||||
|
})
|
||||||
|
export class PreviewTextComponent implements OnInit, OnDestroy {
|
||||||
|
@Input({ required: true }) url!: string;
|
||||||
|
@Input() width = 0;
|
||||||
|
@Input() ext: string = '';
|
||||||
|
|
||||||
|
highlighted = signal<string>('Loading...');
|
||||||
|
private ctrl?: AbortController;
|
||||||
|
|
||||||
|
async ngOnInit(): Promise<void> {
|
||||||
|
try {
|
||||||
|
this.ctrl = new AbortController();
|
||||||
|
const res = await fetch(this.url, { signal: this.ctrl.signal });
|
||||||
|
const text = await res.text();
|
||||||
|
const hljs = (await import('highlight.js')).default;
|
||||||
|
let html = '';
|
||||||
|
try {
|
||||||
|
if (this.ext === 'md' || this.ext === 'markdown') {
|
||||||
|
html = hljs.highlight(text, { language: 'markdown' }).value;
|
||||||
|
} else if (this.ext === 'csv') {
|
||||||
|
html = hljs.highlight(text, { language: 'plaintext' as any }).value;
|
||||||
|
} else {
|
||||||
|
html = hljs.highlightAuto(text).value;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
html = (text || '').replace(/[&<>]/g, (c) => ({'&':'&','<':'<','>':'>'}[c] as string));
|
||||||
|
}
|
||||||
|
const withLines = html.split('\n').map(l => `<span class="line">${l || ' '}</span>`).join('\n');
|
||||||
|
this.highlighted.set(withLines);
|
||||||
|
} catch (e) {
|
||||||
|
this.highlighted.set('Failed to load preview.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ngOnDestroy(): void {
|
||||||
|
try { this.ctrl?.abort(); } catch {}
|
||||||
|
}
|
||||||
|
}
|
||||||
17
src/app/blocks/file/viewers/preview-video.component.ts
Normal file
17
src/app/blocks/file/viewers/preview-video.component.ts
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
import { Component, Input } from '@angular/core';
|
||||||
|
import { CommonModule } from '@angular/common';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-preview-video',
|
||||||
|
standalone: true,
|
||||||
|
imports: [CommonModule],
|
||||||
|
template: `
|
||||||
|
<div class="w-full">
|
||||||
|
<video [src]="url" class="w-full rounded-lg shadow-sm" controls playsinline preload="metadata"></video>
|
||||||
|
</div>
|
||||||
|
`
|
||||||
|
})
|
||||||
|
export class PreviewVideoComponent {
|
||||||
|
@Input({ required: true }) url!: string;
|
||||||
|
@Input() width = 0;
|
||||||
|
}
|
||||||
@ -183,6 +183,7 @@ export interface MenuAction {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
@if (convertOptions.length) {
|
||||||
<button
|
<button
|
||||||
class="w-full text-left px-4 py-2 hover:bg-surface2 transition flex items-center gap-3 justify-between"
|
class="w-full text-left px-4 py-2 hover:bg-surface2 transition flex items-center gap-3 justify-between"
|
||||||
[attr.data-submenu]="'convert'"
|
[attr.data-submenu]="'convert'"
|
||||||
@ -195,6 +196,7 @@ export interface MenuAction {
|
|||||||
</div>
|
</div>
|
||||||
<span class="text-xs">›</span>
|
<span class="text-xs">›</span>
|
||||||
</button>
|
</button>
|
||||||
|
}
|
||||||
|
|
||||||
<div class="relative">
|
<div class="relative">
|
||||||
<button
|
<button
|
||||||
@ -728,17 +730,19 @@ export interface MenuAction {
|
|||||||
|
|
||||||
<!-- Convert to submenu -->
|
<!-- Convert to submenu -->
|
||||||
<div
|
<div
|
||||||
*ngIf="showSubmenu === 'convert'"
|
*ngIf="showSubmenu === 'convert' && convertOptions.length"
|
||||||
class="bg-surface1 border border-border rounded-lg shadow-xl min-w-[280px] py-2"
|
class="bg-surface1 border border-border rounded-lg shadow-xl min-w-[280px] py-2"
|
||||||
[attr.data-submenu-panel]="'convert'"
|
[attr.data-submenu-panel]="'convert'"
|
||||||
[ngStyle]="submenuStyle['convert']"
|
[ngStyle]="submenuStyle['convert']"
|
||||||
(mouseenter)="keepSubmenuOpen('convert')"
|
(mouseenter)="keepSubmenuOpen('convert')"
|
||||||
(mouseleave)="closeSubmenu()"
|
(mouseleave)="closeSubmenu()"
|
||||||
|
(mousedown)="$event.stopPropagation()"
|
||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
*ngFor="let item of convertOptions"
|
*ngFor="let item of convertOptions"
|
||||||
class="w-full text-left px-4 py-2 hover:bg-surface2 dark:hover:bg-gray-700 transition flex items-center justify-between"
|
class="w-full text-left px-4 py-2 hover:bg-surface2 dark:hover:bg-gray-700 transition flex items-center justify-between"
|
||||||
(click)="onConvert(item.type, item.preset)"
|
(mousedown)="onConvert(item.type, item.preset)"
|
||||||
|
(click)="$event.preventDefault()"
|
||||||
>
|
>
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-3">
|
||||||
<span class="text-base w-5 flex items-center justify-center">
|
<span class="text-base w-5 flex items-center justify-center">
|
||||||
@ -869,11 +873,12 @@ export class BlockContextMenuComponent implements OnChanges {
|
|||||||
if (!this.visible) return;
|
if (!this.visible) return;
|
||||||
const root = this.menuRef?.nativeElement; if (!root) return;
|
const root = this.menuRef?.nativeElement; if (!root) return;
|
||||||
const target = event.target as HTMLElement;
|
const target = event.target as HTMLElement;
|
||||||
if (!root.contains(target)) return;
|
// Don't close if hovering over a submenu panel (they are fixed-positioned outside root)
|
||||||
const panel = this.showSubmenu ? document.querySelector(`[data-submenu-panel="${this.showSubmenu}"]`) as HTMLElement | null : null;
|
const panel = this.showSubmenu ? document.querySelector(`[data-submenu-panel="${this.showSubmenu}"]`) as HTMLElement | null : null;
|
||||||
const overPanel = panel ? panel.contains(target) : false;
|
if (panel && panel.contains(target)) return;
|
||||||
|
if (!root.contains(target)) return;
|
||||||
const overAnchor = this._submenuAnchor ? (this._submenuAnchor === target || this._submenuAnchor.contains(target)) : false;
|
const overAnchor = this._submenuAnchor ? (this._submenuAnchor === target || this._submenuAnchor.contains(target)) : false;
|
||||||
if (overPanel || overAnchor) return;
|
if (overAnchor) return;
|
||||||
const rowWithSubmenu = target.closest('[data-submenu]') as HTMLElement | null;
|
const rowWithSubmenu = target.closest('[data-submenu]') as HTMLElement | null;
|
||||||
if (!rowWithSubmenu) {
|
if (!rowWithSubmenu) {
|
||||||
this.closeSubmenu();
|
this.closeSubmenu();
|
||||||
@ -1000,8 +1005,8 @@ export class BlockContextMenuComponent implements OnChanges {
|
|||||||
// ensure fixed positioning so it never affects the main menu scroll area
|
// ensure fixed positioning so it never affects the main menu scroll area
|
||||||
panel.style.position = 'fixed';
|
panel.style.position = 'fixed';
|
||||||
panel.style.maxHeight = Math.max(100, vh - 16) + 'px';
|
panel.style.maxHeight = Math.max(100, vh - 16) + 'px';
|
||||||
// First try opening to the right (tight gap)
|
// First try opening to the right (small gap to allow mouse travel)
|
||||||
let left = r.right + 2;
|
let left = r.right + 4;
|
||||||
// place top aligned with anchor top
|
// place top aligned with anchor top
|
||||||
let top = r.top;
|
let top = r.top;
|
||||||
// Measure panel size (after position temp offscreen)
|
// Measure panel size (after position temp offscreen)
|
||||||
@ -1109,7 +1114,8 @@ export class BlockContextMenuComponent implements OnChanges {
|
|||||||
this.previewState = { kind: null };
|
this.previewState = { kind: null };
|
||||||
}
|
}
|
||||||
|
|
||||||
convertOptions = [
|
get convertOptions() {
|
||||||
|
const base = [
|
||||||
{ type: 'list-item' as BlockType, preset: { kind: 'check', checked: false, text: '' }, icon: '☑️', label: 'Checkbox list', shortcut: 'ctrl+shift+c' },
|
{ type: 'list-item' as BlockType, preset: { kind: 'check', checked: false, text: '' }, icon: '☑️', label: 'Checkbox list', shortcut: 'ctrl+shift+c' },
|
||||||
{ type: 'list-item' as BlockType, preset: { kind: 'numbered', number: 1, text: '' }, icon: '1.', label: 'Numbered list', shortcut: 'ctrl+shift+7' },
|
{ type: 'list-item' as BlockType, preset: { kind: 'numbered', number: 1, text: '' }, icon: '1.', label: 'Numbered list', shortcut: 'ctrl+shift+7' },
|
||||||
{ type: 'list-item' as BlockType, preset: { kind: 'bullet', text: '' }, icon: '•', label: 'Bullet list', shortcut: 'ctrl+shift+8' },
|
{ type: 'list-item' as BlockType, preset: { kind: 'bullet', text: '' }, icon: '•', label: 'Bullet list', shortcut: 'ctrl+shift+8' },
|
||||||
@ -1125,6 +1131,28 @@ export class BlockContextMenuComponent implements OnChanges {
|
|||||||
{ type: 'button' as BlockType, preset: null, icon: '🔘', label: 'Button', shortcut: 'ctrl+alt+5' }
|
{ type: 'button' as BlockType, preset: null, icon: '🔘', label: 'Button', shortcut: 'ctrl+alt+5' }
|
||||||
];
|
];
|
||||||
|
|
||||||
|
// Restrict for file/image per requirements
|
||||||
|
if (this.block?.type === 'file') {
|
||||||
|
// only when underlying file is an image kind
|
||||||
|
const props: any = this.block.props || {};
|
||||||
|
const meta = props.meta || {};
|
||||||
|
let kind = meta.kind as string | undefined;
|
||||||
|
if (!kind) {
|
||||||
|
const name = meta.name || props.name || '';
|
||||||
|
const ext = (meta.ext || name.split('.').pop() || '').toLowerCase();
|
||||||
|
if (['png','jpg','jpeg','gif','webp','bmp','svg'].includes(ext)) kind = 'image';
|
||||||
|
}
|
||||||
|
if (kind === 'image') {
|
||||||
|
return [{ type: 'image' as BlockType, preset: null, icon: '🖼️', label: 'Image', shortcut: '' }];
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
if (this.block?.type === 'image') {
|
||||||
|
return [{ type: 'file' as BlockType, preset: null, icon: '📎', label: 'File', shortcut: '' }];
|
||||||
|
}
|
||||||
|
return base;
|
||||||
|
}
|
||||||
|
|
||||||
backgroundColors = [
|
backgroundColors = [
|
||||||
{ name: 'None', value: 'transparent' },
|
{ name: 'None', value: 'transparent' },
|
||||||
// row 1 (reds/pinks/purples)
|
// row 1 (reds/pinks/purples)
|
||||||
|
|||||||
@ -74,7 +74,7 @@ import { ColumnsBlockComponent } from './blocks/columns-block.component';
|
|||||||
[attr.data-block-index]="index"
|
[attr.data-block-index]="index"
|
||||||
[class.active]="isActive()"
|
[class.active]="isActive()"
|
||||||
[class.locked]="block.meta?.locked"
|
[class.locked]="block.meta?.locked"
|
||||||
[style.background-color]="block.type === 'list-item' ? null : block.meta?.bgColor"
|
[style.background-color]="(block.type === 'list-item' || block.type === 'file') ? null : block.meta?.bgColor"
|
||||||
[ngStyle]="blockStyles()"
|
[ngStyle]="blockStyles()"
|
||||||
(click)="onBlockClick($event)"
|
(click)="onBlockClick($event)"
|
||||||
>
|
>
|
||||||
|
|||||||
@ -28,6 +28,7 @@ import { OutlineBlockComponent } from './outline-block.component';
|
|||||||
import { ListBlockComponent } from './list-block.component';
|
import { ListBlockComponent } from './list-block.component';
|
||||||
import { CommentsPanelComponent } from '../../comments/comments-panel.component';
|
import { CommentsPanelComponent } from '../../comments/comments-panel.component';
|
||||||
import { BlockContextMenuComponent } from '../block-context-menu.component';
|
import { BlockContextMenuComponent } from '../block-context-menu.component';
|
||||||
|
import { DragDropFilesDirective } from '../../../../blocks/file/directives/drag-drop-files.directive';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-columns-block',
|
selector: 'app-columns-block',
|
||||||
@ -54,10 +55,11 @@ import { BlockContextMenuComponent } from '../block-context-menu.component';
|
|||||||
OutlineBlockComponent,
|
OutlineBlockComponent,
|
||||||
ListBlockComponent,
|
ListBlockComponent,
|
||||||
CommentsPanelComponent,
|
CommentsPanelComponent,
|
||||||
BlockContextMenuComponent
|
BlockContextMenuComponent,
|
||||||
|
DragDropFilesDirective
|
||||||
],
|
],
|
||||||
template: `
|
template: `
|
||||||
<div class="flex gap-6 w-full relative" #columnsContainer>
|
<div class="flex gap-6 w-full relative" #columnsContainer [appDragDropFiles]="{ type: 'columns', columnsBlockId: block.id }">
|
||||||
@for (column of props.columns; track column.id; let colIndex = $index) {
|
@for (column of props.columns; track column.id; let colIndex = $index) {
|
||||||
<div
|
<div
|
||||||
class="flex-1 min-w-0"
|
class="flex-1 min-w-0"
|
||||||
|
|||||||
@ -1,39 +1,193 @@
|
|||||||
import { Component, Input, Output, EventEmitter } from '@angular/core';
|
import { Component, Input, Output, EventEmitter, OnDestroy, signal, inject, HostListener, ElementRef, ViewChild } from '@angular/core';
|
||||||
import { CommonModule } from '@angular/common';
|
import { CommonModule } from '@angular/common';
|
||||||
import { Block, FileProps } from '../../../core/models/block.model';
|
import { Block } from '../../../core/models/block.model';
|
||||||
|
import { FilePreviewComponent } from '../../../../blocks/file/file-preview.component';
|
||||||
|
import { FileIconComponent } from '../../../../blocks/file/pipes/file-icon.pipe';
|
||||||
|
import { FileSizePipe } from '../../../../blocks/file/pipes/file-size.pipe';
|
||||||
|
import { FileMeta } from '../../../../blocks/file/models/file-models';
|
||||||
|
import { DocumentService } from '../../../services/document.service';
|
||||||
|
import { FilePickerService } from '../../../../blocks/file/services/file-picker.service';
|
||||||
|
import { FileMimeService } from '../../../../blocks/file/services/file-mime.service';
|
||||||
|
|
||||||
|
interface LegacyFileProps { name: string; url: string; size?: number; mime?: string; }
|
||||||
|
|
||||||
|
type FileBlockProps = { meta: FileMeta; ui: { expanded: boolean; layout: 'list'|'grid'; widthPx?: number } } | LegacyFileProps;
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-file-block',
|
selector: 'app-file-block',
|
||||||
standalone: true,
|
standalone: true,
|
||||||
imports: [CommonModule],
|
imports: [CommonModule, FilePreviewComponent, FileIconComponent, FileSizePipe],
|
||||||
template: `
|
template: `
|
||||||
<div class="flex items-center gap-2 px-3 py-2 rounded-md border bg-surface1">
|
<div class="relative text-[--fg]">
|
||||||
<div class="text-2xl leading-none">📎</div>
|
<!-- Banner pill matching Color block shape (less rounded, uses host bgColor) -->
|
||||||
<div class="flex-1">
|
<button type="button"
|
||||||
<div class="font-semibold text-sm leading-5">{{ props.name || 'Untitled file' }}</div>
|
class="w-full px-4 py-2 flex items-center gap-3 rounded-md ring-1 ring-border focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary"
|
||||||
@if (props.size) {
|
[style.background-color]="block.meta?.bgColor || null"
|
||||||
<div class="text-xs text-text-muted">{{ formatSize(props.size) }}</div>
|
role="button" [attr.aria-expanded]="expanded()" (click)="toggle()" (keydown.enter)="toggle()" (keydown.space)="toggle(); $event.preventDefault()">
|
||||||
}
|
<span class="size-9 rounded-full bg-[--muted] flex items-center justify-center text-xl">
|
||||||
</div>
|
<app-file-icon [ext]="meta().ext" [kind]="meta().kind"></app-file-icon>
|
||||||
@if (props.url) {
|
</span>
|
||||||
<a [href]="props.url" target="_blank" class="btn btn-xs btn-primary">
|
<span class="font-medium truncate" [title]="meta().name">{{ meta().name || 'Untitled file' }}</span>
|
||||||
Download
|
<span class="text-xs opacity-70">{{ meta().size | fileSize }}</span>
|
||||||
|
<a class="ml-auto text-sm px-2 py-1 rounded hover:bg-surface2"
|
||||||
|
[href]="meta().url" download [attr.aria-label]="'Download ' + meta().name" (click)="$event.stopPropagation()">
|
||||||
|
⬇
|
||||||
</a>
|
</a>
|
||||||
|
<span class="relative">
|
||||||
|
<button #moreBtn type="button" class="text-sm px-2 py-1 rounded hover:bg-surface2" title="More"
|
||||||
|
(click)="$event.stopPropagation(); toggleMenu()">
|
||||||
|
⋮
|
||||||
|
</button>
|
||||||
|
@if (menuOpen) {
|
||||||
|
<div class="fixed z-[2147483646] bg-surface1 border border-border rounded-lg shadow-xl py-1 min-w-[200px] text-sm"
|
||||||
|
[style.left.px]="menuPos.left" [style.top.px]="menuPos.top">
|
||||||
|
@if (isImageKind()) {
|
||||||
|
<button class="w-full text-left px-4 py-2 hover:bg-surface2 transition" (click)="onConvertToImage()">🖼️ Convert to Image</button>
|
||||||
|
<div class="h-px my-1 bg-border"></div>
|
||||||
|
}
|
||||||
|
<button class="w-full text-left px-4 py-2 hover:bg-surface2 transition" (click)="onRename()">Rename</button>
|
||||||
|
<button class="w-full text-left px-4 py-2 hover:bg-surface2 transition" (click)="onReplace()">Replace</button>
|
||||||
|
<button class="w-full text-left px-4 py-2 hover:bg-surface2 transition" (click)="onCopyLink()">Copy link</button>
|
||||||
|
<div class="h-px my-1 bg-border"></div>
|
||||||
|
<button class="w-full text-left px-4 py-2 text-red-500 hover:bg-red-500/10 transition" (click)="onDelete()">Delete</button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</span>
|
||||||
|
@if (hasPreview()) {
|
||||||
|
<span class="px-2 py-1 text-sm opacity-70">{{ expanded() ? 'Collapse' : 'Preview' }}</span>
|
||||||
|
}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<!-- Preview -->
|
||||||
|
@if (expanded()) {
|
||||||
|
<div class="px-1 pt-2">
|
||||||
|
<app-file-preview [meta]="meta()" [expanded]="expanded()" />
|
||||||
|
</div>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
`
|
`
|
||||||
})
|
})
|
||||||
export class FileBlockComponent {
|
export class FileBlockComponent implements OnDestroy {
|
||||||
@Input({ required: true }) block!: Block<FileProps>;
|
@Input({ required: true }) block!: Block<FileBlockProps>;
|
||||||
@Output() update = new EventEmitter<FileProps>();
|
@Output() update = new EventEmitter<any>();
|
||||||
|
@Output() menuAction = new EventEmitter<{type: string; payload?: any}>();
|
||||||
|
|
||||||
get props(): FileProps {
|
private readonly docs = inject(DocumentService);
|
||||||
return this.block.props;
|
private readonly picker = inject(FilePickerService);
|
||||||
|
private readonly hostEl = inject(ElementRef<HTMLElement>);
|
||||||
|
private readonly mimeService = inject(FileMimeService);
|
||||||
|
|
||||||
|
expanded = signal(false);
|
||||||
|
menuOpen = false;
|
||||||
|
@ViewChild('moreBtn') moreBtn?: ElementRef<HTMLButtonElement>;
|
||||||
|
menuPos = { left: 0, top: 0 };
|
||||||
|
|
||||||
|
ngOnInit(): void {
|
||||||
|
const p: any = this.block.props as any;
|
||||||
|
if ('meta' in p && p.meta) {
|
||||||
|
this.expanded.set(!!p.ui?.expanded);
|
||||||
|
} else {
|
||||||
|
// Backward compatibility: adapt legacy props to new structure on the fly
|
||||||
|
const legacy = p as LegacyFileProps;
|
||||||
|
const ext = (legacy.name?.split('.').pop() || '').toLowerCase();
|
||||||
|
const meta: FileMeta = {
|
||||||
|
id: this.block.id,
|
||||||
|
name: legacy.name || 'Untitled file',
|
||||||
|
size: legacy.size ?? 0,
|
||||||
|
mime: legacy.mime || '',
|
||||||
|
ext,
|
||||||
|
kind: this.mimeService.kindFromExt(ext),
|
||||||
|
createdAt: Date.now(),
|
||||||
|
url: legacy.url || ''
|
||||||
|
};
|
||||||
|
this.docs.updateBlockProps(this.block.id, { meta, ui: { expanded: false, layout: 'list' } });
|
||||||
|
this.expanded.set(false);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
formatSize(bytes: number): string {
|
meta() { return (this.block.props as any).meta as FileMeta; }
|
||||||
if (bytes < 1024) return bytes + ' B';
|
|
||||||
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
|
isImageKind(): boolean {
|
||||||
return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
|
const k = this.meta()?.kind;
|
||||||
|
if (k === 'image') return true;
|
||||||
|
const ext = (this.meta()?.ext || '').toLowerCase();
|
||||||
|
return ['png','jpg','jpeg','gif','webp','bmp','svg'].includes(ext);
|
||||||
|
}
|
||||||
|
|
||||||
|
hasPreview(): boolean {
|
||||||
|
const k = this.meta()?.kind;
|
||||||
|
return ['image','video','pdf','text','code','docx'].includes(k);
|
||||||
|
}
|
||||||
|
|
||||||
|
toggle(): void {
|
||||||
|
const current = !this.expanded();
|
||||||
|
this.expanded.set(current);
|
||||||
|
const ui = { ...((this.block.props as any).ui || {}), expanded: current };
|
||||||
|
this.docs.updateBlockProps(this.block.id, { ui });
|
||||||
|
}
|
||||||
|
|
||||||
|
toggleMenu() {
|
||||||
|
this.menuOpen = !this.menuOpen;
|
||||||
|
if (this.menuOpen) this.positionMenu();
|
||||||
|
}
|
||||||
|
|
||||||
|
@HostListener('document:keydown.escape')
|
||||||
|
onEsc() { this.menuOpen = false; }
|
||||||
|
|
||||||
|
@HostListener('document:click', ['$event'])
|
||||||
|
onDocClick(ev: MouseEvent) {
|
||||||
|
if (!this.menuOpen) return;
|
||||||
|
const el = this.hostEl.nativeElement as HTMLElement;
|
||||||
|
if (!el.contains(ev.target as Node)) this.menuOpen = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
@HostListener('window:resize')
|
||||||
|
@HostListener('window:scroll')
|
||||||
|
onWindowChange() { if (this.menuOpen) this.positionMenu(); }
|
||||||
|
|
||||||
|
private positionMenu() {
|
||||||
|
const btn = this.moreBtn?.nativeElement; if (!btn) return;
|
||||||
|
const r = btn.getBoundingClientRect();
|
||||||
|
const top = Math.round(r.bottom + 6);
|
||||||
|
const left = Math.round(r.right - 220); // align right edge roughly
|
||||||
|
this.menuPos = { left: Math.max(8, left), top: Math.max(8, top) };
|
||||||
|
}
|
||||||
|
|
||||||
|
async onRename(): Promise<void> {
|
||||||
|
this.menuOpen = false;
|
||||||
|
const name = prompt('Rename file', this.meta().name);
|
||||||
|
if (name && name !== this.meta().name) {
|
||||||
|
this.docs.updateBlockProps(this.block.id, { meta: { ...this.meta(), name } });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async onReplace(): Promise<void> {
|
||||||
|
this.menuOpen = false;
|
||||||
|
const files = await this.picker.pick({ multiple: false, accept: '*/*' });
|
||||||
|
if (!files.length) return;
|
||||||
|
const f = files[0];
|
||||||
|
try { if (this.meta().url?.startsWith('blob:')) URL.revokeObjectURL(this.meta().url); } catch {}
|
||||||
|
const url = URL.createObjectURL(f);
|
||||||
|
const updated: FileMeta = { ...this.meta(), name: f.name, size: f.size, url };
|
||||||
|
this.docs.updateBlockProps(this.block.id, { meta: updated });
|
||||||
|
}
|
||||||
|
|
||||||
|
async onCopyLink(): Promise<void> {
|
||||||
|
this.menuOpen = false;
|
||||||
|
try { await navigator.clipboard.writeText(this.meta().url); } catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
onDelete(): void {
|
||||||
|
this.menuOpen = false;
|
||||||
|
this.docs.deleteBlock(this.block.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
onConvertToImage(): void {
|
||||||
|
this.menuOpen = false;
|
||||||
|
this.docs.convertBlock(this.block.id, 'image');
|
||||||
|
}
|
||||||
|
|
||||||
|
ngOnDestroy(): void {
|
||||||
|
// Do not revoke blob URL here to ensure conversions keep working
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -7,6 +7,8 @@ import { DocumentService } from '../../../services/document.service';
|
|||||||
import { SelectionService } from '../../../services/selection.service';
|
import { SelectionService } from '../../../services/selection.service';
|
||||||
import { PaletteService } from '../../../services/palette.service';
|
import { PaletteService } from '../../../services/palette.service';
|
||||||
import { PaletteCategory, PaletteItem, getPaletteItemsByCategory } from '../../../core/constants/palette-items';
|
import { PaletteCategory, PaletteItem, getPaletteItemsByCategory } from '../../../core/constants/palette-items';
|
||||||
|
import { FilePickerService } from '../../../../blocks/file/services/file-picker.service';
|
||||||
|
import { BlockInsertionService } from '../../../../blocks/file/services/block-insertion.service';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-paragraph-block',
|
selector: 'app-paragraph-block',
|
||||||
@ -123,6 +125,8 @@ export class ParagraphBlockComponent implements AfterViewInit {
|
|||||||
private documentService = inject(DocumentService);
|
private documentService = inject(DocumentService);
|
||||||
private selectionService = inject(SelectionService);
|
private selectionService = inject(SelectionService);
|
||||||
private paletteService = inject(PaletteService);
|
private paletteService = inject(PaletteService);
|
||||||
|
private filePicker = inject(FilePickerService);
|
||||||
|
private blockInserter = inject(BlockInsertionService);
|
||||||
@ViewChild('editable', { static: true }) editable?: ElementRef<HTMLDivElement>;
|
@ViewChild('editable', { static: true }) editable?: ElementRef<HTMLDivElement>;
|
||||||
|
|
||||||
isFocused = signal(false);
|
isFocused = signal(false);
|
||||||
@ -163,8 +167,8 @@ export class ParagraphBlockComponent implements AfterViewInit {
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case 'file': {
|
case 'file': {
|
||||||
this.documentService.convertBlock(id, 'file');
|
this.handleFileInsertion(id);
|
||||||
break;
|
return;
|
||||||
}
|
}
|
||||||
case 'heading-2': {
|
case 'heading-2': {
|
||||||
this.documentService.convertBlock(id, 'heading');
|
this.documentService.convertBlock(id, 'heading');
|
||||||
@ -218,8 +222,8 @@ export class ParagraphBlockComponent implements AfterViewInit {
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case 'file': {
|
case 'file': {
|
||||||
this.documentService.convertBlock(id, 'file');
|
this.handleFileInsertion(id);
|
||||||
break;
|
return;
|
||||||
}
|
}
|
||||||
case 'paragraph': {
|
case 'paragraph': {
|
||||||
this.documentService.convertBlock(id, 'paragraph');
|
this.documentService.convertBlock(id, 'paragraph');
|
||||||
@ -243,7 +247,7 @@ export class ParagraphBlockComponent implements AfterViewInit {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
this.moreOpen.set(false);
|
this.moreOpen.set(false);
|
||||||
// Keep focus on the same editable after conversion
|
// Keep focus on the same editable after conversion (if still present)
|
||||||
setTimeout(() => this.editable?.nativeElement?.focus(), 0);
|
setTimeout(() => this.editable?.nativeElement?.focus(), 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -395,4 +399,19 @@ export class ParagraphBlockComponent implements AfterViewInit {
|
|||||||
nextEl?.focus();
|
nextEl?.focus();
|
||||||
}, 0);
|
}, 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private handleFileInsertion(blockId: string): void {
|
||||||
|
this.filePicker.pick({ multiple: true, accept: '*/*' }).then(async files => {
|
||||||
|
if (!files.length) return;
|
||||||
|
const blocks = this.documentService.blocks();
|
||||||
|
const insertIndex = blocks.findIndex(b => b.id === blockId);
|
||||||
|
if (insertIndex < 0) return;
|
||||||
|
|
||||||
|
this.documentService.deleteBlock(blockId);
|
||||||
|
const created = await this.blockInserter.createFromFiles(files, insertIndex);
|
||||||
|
if (created.length) {
|
||||||
|
this.selectionService.setActive(created[created.length - 1]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -13,12 +13,15 @@ import { TocButtonComponent } from '../toc/toc-button.component';
|
|||||||
import { TocPanelComponent } from '../toc/toc-panel.component';
|
import { TocPanelComponent } from '../toc/toc-panel.component';
|
||||||
import { UnsplashPickerComponent } from '../unsplash/unsplash-picker.component';
|
import { UnsplashPickerComponent } from '../unsplash/unsplash-picker.component';
|
||||||
import { DragDropService } from '../../services/drag-drop.service';
|
import { DragDropService } from '../../services/drag-drop.service';
|
||||||
|
import { DragDropFilesDirective } from '../../../blocks/file/directives/drag-drop-files.directive';
|
||||||
|
import { FilePickerService } from '../../../blocks/file/services/file-picker.service';
|
||||||
|
import { BlockInsertionService } from '../../../blocks/file/services/block-insertion.service';
|
||||||
import { PaletteItem } from '../../core/constants/palette-items';
|
import { PaletteItem } from '../../core/constants/palette-items';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-editor-shell',
|
selector: 'app-editor-shell',
|
||||||
standalone: true,
|
standalone: true,
|
||||||
imports: [CommonModule, FormsModule, BlockHostComponent, BlockMenuComponent, TocButtonComponent, TocPanelComponent, UnsplashPickerComponent],
|
imports: [CommonModule, FormsModule, BlockHostComponent, BlockMenuComponent, TocButtonComponent, TocPanelComponent, UnsplashPickerComponent, DragDropFilesDirective],
|
||||||
template: `
|
template: `
|
||||||
<div class="grid h-full w-full grid-rows-[auto,1fr] grid-cols-[1fr,auto] overflow-hidden">
|
<div class="grid h-full w-full grid-rows-[auto,1fr] grid-cols-[1fr,auto] overflow-hidden">
|
||||||
<div class="row-[1] col-[1/3] px-8 py-4 bg-card dark:bg-main border-b border-border" (click)="onShellClick()">
|
<div class="row-[1] col-[1/3] px-8 py-4 bg-card dark:bg-main border-b border-border" (click)="onShellClick()">
|
||||||
@ -45,7 +48,7 @@ import { PaletteItem } from '../../core/constants/palette-items';
|
|||||||
<div class="row-[2] col-[1] overflow-y-auto min-h-0">
|
<div class="row-[2] col-[1] overflow-y-auto min-h-0">
|
||||||
<div class="mx-auto w-full px-8 py-4 bg-card dark:bg-main relative" (click)="onShellClick()">
|
<div class="mx-auto w-full px-8 py-4 bg-card dark:bg-main relative" (click)="onShellClick()">
|
||||||
|
|
||||||
<div #blockList class="flex flex-col gap-1.5 relative" (dblclick)="onBlockListDoubleClick($event)">
|
<div #blockList class="flex flex-col gap-1.5 relative" (dblclick)="onBlockListDoubleClick($event)" [appDragDropFiles]="'root'">
|
||||||
@for (block of documentService.blocks(); track block.id; let idx = $index) {
|
@for (block of documentService.blocks(); track block.id; let idx = $index) {
|
||||||
<app-block-host
|
<app-block-host
|
||||||
[block]="block"
|
[block]="block"
|
||||||
@ -182,6 +185,8 @@ export class EditorShellComponent implements AfterViewInit {
|
|||||||
readonly shortcutsService = inject(ShortcutsService);
|
readonly shortcutsService = inject(ShortcutsService);
|
||||||
readonly tocService = inject(TocService);
|
readonly tocService = inject(TocService);
|
||||||
readonly dragDrop = inject(DragDropService);
|
readonly dragDrop = inject(DragDropService);
|
||||||
|
readonly filePicker = inject(FilePickerService);
|
||||||
|
readonly inserter = inject(BlockInsertionService);
|
||||||
|
|
||||||
@ViewChild('blockList', { static: true }) blockListRef!: ElementRef<HTMLElement>;
|
@ViewChild('blockList', { static: true }) blockListRef!: ElementRef<HTMLElement>;
|
||||||
|
|
||||||
@ -259,13 +264,20 @@ export class EditorShellComponent implements AfterViewInit {
|
|||||||
'bullet-list': { type: 'list-item' as any, props: { kind: 'bullet', text: '' } },
|
'bullet-list': { type: 'list-item' as any, props: { kind: 'bullet', text: '' } },
|
||||||
'table': { type: 'table', props: this.documentService.getDefaultProps('table') },
|
'table': { type: 'table', props: this.documentService.getDefaultProps('table') },
|
||||||
'image': { type: 'image', props: this.documentService.getDefaultProps('image') },
|
'image': { type: 'image', props: this.documentService.getDefaultProps('image') },
|
||||||
'file': { type: 'file', props: this.documentService.getDefaultProps('file') },
|
'file': null,
|
||||||
'heading-2': { type: 'heading', props: { level: 2, text: '' } },
|
'heading-2': { type: 'heading', props: { level: 2, text: '' } },
|
||||||
'new-page': { type: 'paragraph', props: { text: '' } }, // Placeholder
|
'new-page': { type: 'paragraph', props: { text: '' } }, // Placeholder
|
||||||
'use-ai': { type: 'paragraph', props: { text: '' } }, // Placeholder
|
'use-ai': { type: 'paragraph', props: { text: '' } }, // Placeholder
|
||||||
};
|
};
|
||||||
|
|
||||||
const config = typeMap[action];
|
const config = typeMap[action];
|
||||||
|
if (action === 'file') {
|
||||||
|
this.filePicker.pick({ multiple: true, accept: '*/*' }).then(files => {
|
||||||
|
if (!files.length) return;
|
||||||
|
this.insertFilesAtCursor(files);
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (config) {
|
if (config) {
|
||||||
const block = this.documentService.createBlock(config.type, config.props);
|
const block = this.documentService.createBlock(config.type, config.props);
|
||||||
this.documentService.appendBlock(block);
|
this.documentService.appendBlock(block);
|
||||||
@ -275,25 +287,24 @@ export class EditorShellComponent implements AfterViewInit {
|
|||||||
}
|
}
|
||||||
|
|
||||||
onPaletteItemSelected(item: PaletteItem): void {
|
onPaletteItemSelected(item: PaletteItem): void {
|
||||||
|
// Special handling for File: open multi-picker and create N blocks
|
||||||
|
if (item.type === 'file' || item.id === 'file') {
|
||||||
|
this.filePicker.pick({ multiple: true, accept: '*/*' }).then(files => {
|
||||||
|
if (!files.length) return;
|
||||||
|
this.insertFilesAtCursor(files);
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Convert list types to list-item for independent lines
|
// Convert list types to list-item for independent lines
|
||||||
let blockType = item.type;
|
let blockType = item.type;
|
||||||
let props = this.documentService.getDefaultProps(blockType);
|
let props = this.documentService.getDefaultProps(blockType);
|
||||||
|
|
||||||
if (item.type === 'list') {
|
if (item.type === 'list') {
|
||||||
// Use list-item instead of list for independent drag & drop
|
|
||||||
blockType = 'list-item' as any;
|
blockType = 'list-item' as any;
|
||||||
props = this.documentService.getDefaultProps(blockType);
|
props = this.documentService.getDefaultProps(blockType);
|
||||||
|
if (item.id === 'checkbox-list') { props.kind = 'check'; props.checked = false; }
|
||||||
// Set the correct kind based on palette item
|
else if (item.id === 'numbered-list') { props.kind = 'numbered'; props.number = 1; }
|
||||||
if (item.id === 'checkbox-list') {
|
else if (item.id === 'bullet-list') { props.kind = 'bullet'; }
|
||||||
props.kind = 'check';
|
|
||||||
props.checked = false;
|
|
||||||
} else if (item.id === 'numbered-list') {
|
|
||||||
props.kind = 'numbered';
|
|
||||||
props.number = 1;
|
|
||||||
} else if (item.id === 'bullet-list') {
|
|
||||||
props.kind = 'bullet';
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const block = this.documentService.createBlock(blockType, props);
|
const block = this.documentService.createBlock(blockType, props);
|
||||||
@ -301,6 +312,42 @@ export class EditorShellComponent implements AfterViewInit {
|
|||||||
this.selectionService.setActive(block.id);
|
this.selectionService.setActive(block.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Insert selected files at the current cursor position, matching '/' menu behavior.
|
||||||
|
* - If an empty paragraph is active, replace it.
|
||||||
|
* - Else insert after the active block.
|
||||||
|
* - Else, if an inline menu placeholder exists, insert after it.
|
||||||
|
* - Else append at the end.
|
||||||
|
*/
|
||||||
|
private insertFilesAtCursor(files: File[]): void {
|
||||||
|
const blocks = this.documentService.blocks();
|
||||||
|
const activeId = this.selectionService.getActive();
|
||||||
|
let insertIndex = blocks.length;
|
||||||
|
let replaceBlockId: string | null = null;
|
||||||
|
|
||||||
|
if (activeId) {
|
||||||
|
const i = blocks.findIndex(b => b.id === activeId);
|
||||||
|
if (i >= 0) {
|
||||||
|
const blk: any = blocks[i];
|
||||||
|
if (blk.type === 'paragraph' && (!blk.props?.text || String(blk.props.text).trim() === '')) {
|
||||||
|
// Replace empty paragraph like initial '/' flow
|
||||||
|
replaceBlockId = blk.id;
|
||||||
|
insertIndex = i;
|
||||||
|
} else {
|
||||||
|
insertIndex = i + 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (this.insertAfterBlockId()) {
|
||||||
|
const idx = blocks.findIndex(b => b.id === this.insertAfterBlockId());
|
||||||
|
if (idx >= 0) insertIndex = idx + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (replaceBlockId) {
|
||||||
|
this.documentService.deleteBlock(replaceBlockId);
|
||||||
|
}
|
||||||
|
this.inserter.createFromFiles(files, insertIndex);
|
||||||
|
}
|
||||||
|
|
||||||
getSaveStateClass(): string {
|
getSaveStateClass(): string {
|
||||||
const state = this.documentService.saveState();
|
const state = this.documentService.saveState();
|
||||||
switch (state) {
|
switch (state) {
|
||||||
@ -447,9 +494,17 @@ export class EditorShellComponent implements AfterViewInit {
|
|||||||
props = this.documentService.getDefaultProps('image');
|
props = this.documentService.getDefaultProps('image');
|
||||||
break;
|
break;
|
||||||
case 'file':
|
case 'file':
|
||||||
blockType = 'file';
|
// Open picker and replace the placeholder paragraph with N file blocks at the same index
|
||||||
props = this.documentService.getDefaultProps('file');
|
const currentBlocks = this.documentService.blocks();
|
||||||
break;
|
const idx = currentBlocks.findIndex(b => b.id === blockId);
|
||||||
|
this.filePicker.pick({ multiple: true, accept: '*/*' }).then(files => {
|
||||||
|
if (!files.length) return;
|
||||||
|
// Delete the placeholder paragraph
|
||||||
|
this.documentService.deleteBlock(blockId);
|
||||||
|
// Insert at original index
|
||||||
|
this.inserter.createFromFiles(files, idx);
|
||||||
|
});
|
||||||
|
return; // early exit; we handle asynchronously
|
||||||
}
|
}
|
||||||
|
|
||||||
// Convert the existing block
|
// Convert the existing block
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import { Component, inject, Output, EventEmitter, signal, computed, ViewChild, ElementRef } from '@angular/core';
|
import { Component, inject, Output, EventEmitter, signal, computed, ViewChild, ElementRef, effect } from '@angular/core';
|
||||||
import { CommonModule } from '@angular/common';
|
import { CommonModule } from '@angular/common';
|
||||||
import { FormsModule } from '@angular/forms';
|
import { FormsModule } from '@angular/forms';
|
||||||
import { PaletteService } from '../../services/palette.service';
|
import { PaletteService } from '../../services/palette.service';
|
||||||
@ -163,6 +163,7 @@ export class BlockMenuComponent {
|
|||||||
readonly paletteService = inject(PaletteService);
|
readonly paletteService = inject(PaletteService);
|
||||||
@Output() itemSelected = new EventEmitter<PaletteItem>();
|
@Output() itemSelected = new EventEmitter<PaletteItem>();
|
||||||
@ViewChild('menuPanel') menuPanel?: ElementRef<HTMLDivElement>;
|
@ViewChild('menuPanel') menuPanel?: ElementRef<HTMLDivElement>;
|
||||||
|
@ViewChild('searchInput') searchInput?: ElementRef<HTMLInputElement>;
|
||||||
|
|
||||||
showSuggestions = signal(true);
|
showSuggestions = signal(true);
|
||||||
selectedItem = signal<PaletteItem | null>(null);
|
selectedItem = signal<PaletteItem | null>(null);
|
||||||
@ -179,8 +180,27 @@ export class BlockMenuComponent {
|
|||||||
|
|
||||||
newItems = ['steps', 'kanban', 'progress', 'dropdown', 'unsplash'];
|
newItems = ['steps', 'kanban', 'progress', 'dropdown', 'unsplash'];
|
||||||
|
|
||||||
|
// Ensure focus moves to the search input whenever the palette opens
|
||||||
|
// or when the suggestions section becomes visible
|
||||||
|
private _focusEffect = effect(() => {
|
||||||
|
const isOpen = this.paletteService.isOpen();
|
||||||
|
const show = this.showSuggestions();
|
||||||
|
if (isOpen && show) {
|
||||||
|
// Defer to next tick so the input exists in the DOM
|
||||||
|
setTimeout(() => {
|
||||||
|
try { this.searchInput?.nativeElement?.focus(); } catch {}
|
||||||
|
}, 0);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
toggleSuggestions(): void {
|
toggleSuggestions(): void {
|
||||||
this.showSuggestions.update(v => !v);
|
this.showSuggestions.update(v => !v);
|
||||||
|
// If suggestions become visible while open, focus the input
|
||||||
|
if (this.paletteService.isOpen() && this.showSuggestions()) {
|
||||||
|
setTimeout(() => {
|
||||||
|
try { this.searchInput?.nativeElement?.focus(); } catch {}
|
||||||
|
}, 0);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
getItemsByCategory(category: PaletteCategory): PaletteItem[] {
|
getItemsByCategory(category: PaletteCategory): PaletteItem[] {
|
||||||
|
|||||||
@ -304,56 +304,201 @@ export class DocumentService {
|
|||||||
* Convert props when changing block type
|
* Convert props when changing block type
|
||||||
*/
|
*/
|
||||||
private convertProps(fromType: BlockType, fromProps: any, toType: BlockType, preset?: any): any {
|
private convertProps(fromType: BlockType, fromProps: any, toType: BlockType, preset?: any): any {
|
||||||
// If preset provided, use it
|
// File -> Image
|
||||||
if (preset) return { ...preset };
|
if (fromType === 'file' && toType === 'image') {
|
||||||
|
const meta = (fromProps && fromProps.meta) || {};
|
||||||
// Paragraph -> Heading
|
const url = meta.url || fromProps.url || '';
|
||||||
if (fromType === 'paragraph' && toType === 'heading') {
|
const name = meta.name || '';
|
||||||
return { level: preset?.level || 1, text: fromProps.text || '' };
|
return { src: url, alt: name };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Paragraph -> List
|
// Image -> File
|
||||||
if (fromType === 'paragraph' && toType === 'list') {
|
if (fromType === 'image' && toType === 'file') {
|
||||||
|
const src: string = fromProps?.src || '';
|
||||||
|
const nameGuess = ((): string => {
|
||||||
|
try {
|
||||||
|
const u = new URL(src, window.location.origin);
|
||||||
|
const last = u.pathname.split('/').pop() || 'image';
|
||||||
|
return last;
|
||||||
|
} catch { return 'image'; }
|
||||||
|
})();
|
||||||
|
const ext = (nameGuess.split('.').pop() || '').toLowerCase();
|
||||||
|
const meta = {
|
||||||
|
id: generateId(),
|
||||||
|
name: nameGuess,
|
||||||
|
size: 0,
|
||||||
|
mime: ext === 'png' ? 'image/png' : ext === 'jpg' || ext === 'jpeg' ? 'image/jpeg' : '',
|
||||||
|
ext,
|
||||||
|
kind: 'image',
|
||||||
|
createdAt: Date.now(),
|
||||||
|
url: src
|
||||||
|
};
|
||||||
|
const ui = { expanded: false, layout: 'list' as const };
|
||||||
|
return { meta, ui };
|
||||||
|
}
|
||||||
|
|
||||||
|
const text = this.extractTextValue(fromType, fromProps);
|
||||||
|
const marks = this.extractTextMarks(fromProps);
|
||||||
|
|
||||||
|
switch (toType) {
|
||||||
|
case 'paragraph': {
|
||||||
|
const result: any = { text };
|
||||||
|
if (marks?.length) result.marks = marks;
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
case 'heading': {
|
||||||
|
const level = preset?.level ?? fromProps?.level ?? 1;
|
||||||
|
const result: any = { level, text };
|
||||||
|
if (marks?.length) result.marks = marks;
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
case 'list-item': {
|
||||||
|
const kind = preset?.kind ?? fromProps?.kind ?? 'bullet';
|
||||||
|
const result: any = {
|
||||||
|
kind,
|
||||||
|
text,
|
||||||
|
indent: typeof fromProps?.indent === 'number' ? fromProps.indent : 0,
|
||||||
|
align: fromProps?.align ?? 'left'
|
||||||
|
};
|
||||||
|
if (kind === 'check') {
|
||||||
|
result.checked = preset?.checked ?? fromProps?.checked ?? false;
|
||||||
|
} else if (kind === 'numbered') {
|
||||||
|
result.number = preset?.number ?? fromProps?.number ?? 1;
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
case 'list': {
|
||||||
|
if (fromType === 'list') {
|
||||||
|
return { ...fromProps, kind: preset?.kind ?? fromProps?.kind ?? 'bullet' };
|
||||||
|
}
|
||||||
|
const kind = preset?.kind ?? 'bullet';
|
||||||
|
const item: any = { id: generateId(), text };
|
||||||
|
if (kind === 'check') item.checked = preset?.checked ?? false;
|
||||||
|
return { kind, items: [item] };
|
||||||
|
}
|
||||||
|
case 'code': {
|
||||||
|
const code = fromType === 'code' ? (fromProps?.code ?? '') : text;
|
||||||
return {
|
return {
|
||||||
kind: preset?.kind || 'bullet',
|
code,
|
||||||
items: [{ id: generateId(), text: fromProps.text || '' }]
|
lang: preset?.lang ?? fromProps?.lang ?? '',
|
||||||
|
theme: fromProps?.theme,
|
||||||
|
showLineNumbers: fromProps?.showLineNumbers ?? false,
|
||||||
|
enableWrap: fromProps?.enableWrap ?? false
|
||||||
|
};
|
||||||
|
}
|
||||||
|
case 'quote': {
|
||||||
|
return { text, author: fromProps?.author };
|
||||||
|
}
|
||||||
|
case 'hint': {
|
||||||
|
return {
|
||||||
|
text,
|
||||||
|
variant: preset?.variant ?? fromProps?.variant ?? 'info',
|
||||||
|
borderColor: fromProps?.borderColor,
|
||||||
|
lineColor: fromProps?.lineColor,
|
||||||
|
icon: fromProps?.icon
|
||||||
|
};
|
||||||
|
}
|
||||||
|
case 'button': {
|
||||||
|
return {
|
||||||
|
label: text || preset?.label || 'Button',
|
||||||
|
url: fromProps?.url ?? '',
|
||||||
|
variant: preset?.variant ?? fromProps?.variant ?? 'primary'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
case 'toggle': {
|
||||||
|
const content = Array.isArray(fromProps?.content)
|
||||||
|
? this.cloneBlocks(fromProps.content)
|
||||||
|
: [];
|
||||||
|
return {
|
||||||
|
title: text || preset?.title || 'Toggle',
|
||||||
|
content,
|
||||||
|
collapsed: fromProps?.collapsed ?? true
|
||||||
|
};
|
||||||
|
}
|
||||||
|
case 'dropdown': {
|
||||||
|
const content = Array.isArray(fromProps?.content)
|
||||||
|
? this.cloneBlocks(fromProps.content)
|
||||||
|
: [];
|
||||||
|
return {
|
||||||
|
title: text || preset?.title || 'Dropdown',
|
||||||
|
content,
|
||||||
|
collapsed: fromProps?.collapsed ?? true
|
||||||
|
};
|
||||||
|
}
|
||||||
|
case 'steps': {
|
||||||
|
if (fromType === 'steps' && Array.isArray(fromProps?.steps)) {
|
||||||
|
return {
|
||||||
|
steps: fromProps.steps.map((step: any) => ({
|
||||||
|
...step,
|
||||||
|
id: step.id ?? generateId()
|
||||||
|
}))
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
steps: [
|
||||||
|
{
|
||||||
|
id: generateId(),
|
||||||
|
title: text || 'Step 1',
|
||||||
|
description: '',
|
||||||
|
done: false
|
||||||
|
}
|
||||||
|
]
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// List conversions
|
|
||||||
if (fromType === 'list' && toType === 'list') {
|
|
||||||
return { ...fromProps, kind: preset?.kind || 'bullet' };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Paragraph -> Code
|
if (preset) {
|
||||||
if (fromType === 'paragraph' && toType === 'code') {
|
return { ...preset };
|
||||||
return { code: fromProps.text || '', lang: preset?.lang || '' };
|
|
||||||
}
|
|
||||||
|
|
||||||
// Paragraph -> Quote
|
|
||||||
if (fromType === 'paragraph' && toType === 'quote') {
|
|
||||||
return { text: fromProps.text || '' };
|
|
||||||
}
|
|
||||||
|
|
||||||
// Paragraph -> Hint
|
|
||||||
if (fromType === 'paragraph' && toType === 'hint') {
|
|
||||||
return { text: fromProps.text || '', variant: preset?.variant || 'info' };
|
|
||||||
}
|
|
||||||
|
|
||||||
// Paragraph -> Button
|
|
||||||
if (fromType === 'paragraph' && toType === 'button') {
|
|
||||||
return { label: fromProps.text || 'Button', url: '', variant: 'primary' };
|
|
||||||
}
|
|
||||||
|
|
||||||
// Paragraph -> Toggle/Dropdown
|
|
||||||
if (fromType === 'paragraph' && (toType === 'toggle' || toType === 'dropdown')) {
|
|
||||||
return { title: fromProps.text || 'Toggle', content: [], collapsed: true };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Default: create empty props for target type
|
// Default: create empty props for target type
|
||||||
return this.getDefaultProps(toType);
|
return this.getDefaultProps(toType);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private extractTextValue(fromType: BlockType, props: any): string {
|
||||||
|
if (!props) return '';
|
||||||
|
switch (fromType) {
|
||||||
|
case 'paragraph':
|
||||||
|
case 'heading':
|
||||||
|
case 'quote':
|
||||||
|
case 'hint':
|
||||||
|
return props.text || '';
|
||||||
|
case 'list-item':
|
||||||
|
return props.text || '';
|
||||||
|
case 'code':
|
||||||
|
return props.code || '';
|
||||||
|
case 'button':
|
||||||
|
return props.label || '';
|
||||||
|
case 'toggle':
|
||||||
|
case 'dropdown':
|
||||||
|
return props.title || '';
|
||||||
|
case 'steps':
|
||||||
|
return props.steps?.[0]?.title || '';
|
||||||
|
case 'progress':
|
||||||
|
return props.label || '';
|
||||||
|
default:
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private extractTextMarks(props: any): any[] | undefined {
|
||||||
|
if (!props?.marks) return undefined;
|
||||||
|
try {
|
||||||
|
return Array.isArray(props.marks) ? props.marks.map((mark: any) => ({ ...mark })) : undefined;
|
||||||
|
} catch {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private cloneBlocks(blocks: Block[] = []): Block[] {
|
||||||
|
return blocks.map(block => ({
|
||||||
|
...block,
|
||||||
|
id: block.id ?? generateId(),
|
||||||
|
props: block.props ? { ...block.props } : block.props,
|
||||||
|
children: block.children ? this.cloneBlocks(block.children) : undefined
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get default props for block type
|
* Get default props for block type
|
||||||
*/
|
*/
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 805 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 805 KiB |
Loading…
x
Reference in New Issue
Block a user