feat: add image alignment toolbar with dedicated icons and size controls

- Replaced generic alignment buttons with custom image-specific icons (left/center/right/full width)
- Added "Default size" button to reset image dimensions and aspect ratio
- Implemented modal dialogs for image info and caption editing to replace browser prompts
- Enhanced lateral drop detection to create 2-column layouts when dragging blocks side-by-side
This commit is contained in:
Bruno Charest 2025-11-11 14:16:19 -05:00
parent ee3085ce38
commit 386007d351
8 changed files with 487 additions and 183 deletions

View File

@ -5,7 +5,7 @@ import { DocumentService } from '../../services/document.service';
import { CodeThemeService } from '../../services/code-theme.service'; import { CodeThemeService } from '../../services/code-theme.service';
export interface MenuAction { export interface MenuAction {
type: 'comment' | 'add' | 'convert' | 'background' | 'lineColor' | 'borderColor' | 'codeTheme' | 'codeLanguage' | 'copyCode' | 'toggleWrap' | 'toggleLineNumbers' | 'addCaption' | 'tableLayout' | 'copyTable' | 'filterTable' | 'importCSV' | 'tableHelp' | 'insertColumn' | 'imageAspectRatio' | 'imageAlignment' | 'imageReplace' | 'imageRotate' | 'imageSetPreview' | 'imageOCR' | 'imageDownload' | 'imageViewFull' | 'imageOpenTab' | 'imageInfo' | 'duplicate' | 'copy' | 'lock' | 'copyLink' | 'delete' | 'align' | 'indent'; type: 'comment' | 'add' | 'convert' | 'background' | 'lineColor' | 'borderColor' | 'codeTheme' | 'codeLanguage' | 'copyCode' | 'toggleWrap' | 'toggleLineNumbers' | 'addCaption' | 'tableLayout' | 'copyTable' | 'filterTable' | 'importCSV' | 'tableHelp' | 'insertColumn' | 'imageAspectRatio' | 'imageAlignment' | 'imageDefaultSize' | 'imageReplace' | 'imageRotate' | 'imageSetPreview' | 'imageOCR' | 'imageDownload' | 'imageViewFull' | 'imageOpenTab' | 'imageInfo' | 'duplicate' | 'copy' | 'lock' | 'copyLink' | 'delete' | 'align' | 'indent';
payload?: any; payload?: any;
} }
@ -25,43 +25,99 @@ export interface MenuAction {
(click)="$event.stopPropagation()" (click)="$event.stopPropagation()"
(contextmenu)="$event.preventDefault()" (contextmenu)="$event.preventDefault()"
> >
<!-- Alignment & Indent toolbar (top) --> <!-- Alignment toolbar (image has dedicated icons + size buttons). Non-image keeps generic align + indent. -->
<div class="flex items-center gap-1 px-3 py-2 border-b border-border"> <div class="flex items-center gap-1 px-3 py-2 border-b border-border">
<button @if (block.type === 'image') {
*ngFor="let align of alignments" <!-- Image alignment: Left / Center / Right (custom icons like in ref) -->
class="p-2 rounded hover:bg-surface2 transition" <button
[title]="align.label" class="p-2 rounded hover:bg-surface2 transition"
(click)="onAlign(align.value)" title="Align left"
> (click)="onAlignImage('left')"
<svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"> >
<path *ngFor="let line of align.lines" [attr.d]="line"/> <svg class="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6">
</svg> <rect x="3" y="5.5" width="18" height="13" rx="2" opacity="0.35"/>
</button> <rect x="5" y="8" width="7" height="8" rx="1" fill="currentColor"/>
<div class="w-px h-5 mx-1 bg-border"></div> </svg>
<button </button>
class="p-2 rounded hover:bg-surface2 transition" <button
title="Increase indent" class="p-2 rounded hover:bg-surface2 transition"
(click)="onIndent(1)" title="Align center"
> (click)="onAlignImage('center')"
<svg class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> >
<path d="M8 6h13"/> <svg class="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6">
<path d="M8 12h13"/> <rect x="3" y="5.5" width="18" height="13" rx="2" opacity="0.35"/>
<path d="M8 18h13"/> <rect x="8.5" y="8" width="7" height="8" rx="1" fill="currentColor"/>
<path d="M3 8l4 4-4 4"/> </svg>
</svg> </button>
</button> <button
<button class="p-2 rounded hover:bg-surface2 transition"
class="p-2 rounded hover:bg-surface2 transition" title="Align right"
title="Decrease indent" (click)="onAlignImage('right')"
(click)="onIndent(-1)" >
> <svg class="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6">
<svg class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> <rect x="3" y="5.5" width="18" height="13" rx="2" opacity="0.35"/>
<path d="M8 6h13"/> <rect x="12" y="8" width="7" height="8" rx="1" fill="currentColor"/>
<path d="M8 12h13"/> </svg>
<path d="M8 18h13"/> </button>
<path d="M7 12H3"/> <div class="w-px h-5 mx-1 bg-border"></div>
</svg> <!-- Default size and Full width -->
</button> <button
class="p-2 rounded hover:bg-surface2 transition"
title="Default size"
(click)="onImageDefaultSize()"
>
<svg class="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6">
<rect x="3" y="5.5" width="18" height="13" rx="2" opacity="0.35"/>
<rect x="9" y="8" width="6" height="8" rx="1" fill="currentColor"/>
</svg>
</button>
<button
class="p-2 rounded hover:bg-surface2 transition"
title="Full width"
(click)="onAction('imageAlignment', { alignment: 'full' })"
>
<svg class="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6">
<rect x="3" y="5.5" width="18" height="13" rx="2" opacity="0.35"/>
<rect x="4.5" y="8" width="15" height="8" rx="1" fill="currentColor"/>
</svg>
</button>
} @else {
<button
*ngFor="let align of alignments"
class="p-2 rounded hover:bg-surface2 transition"
[title]="align.label"
(click)="onAlign(align.value)"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path *ngFor="let line of align.lines" [attr.d]="line"/>
</svg>
</button>
<div class="w-px h-5 mx-1 bg-border"></div>
<button
class="p-2 rounded hover:bg-surface2 transition"
title="Increase indent"
(click)="onIndent(1)"
>
<svg class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M8 6h13"/>
<path d="M8 12h13"/>
<path d="M8 18h13"/>
<path d="M3 8l4 4-4 4"/>
</svg>
</button>
<button
class="p-2 rounded hover:bg-surface2 transition"
title="Decrease indent"
(click)="onIndent(-1)"
>
<svg class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M8 6h13"/>
<path d="M8 12h13"/>
<path d="M8 18h13"/>
<path d="M7 12H3"/>
</svg>
</button>
}
</div> </div>
<!-- Image quick ratios row (top, only for image) --> <!-- Image quick ratios row (top, only for image) -->
@ -1066,17 +1122,27 @@ export class BlockContextMenuComponent implements OnChanges {
{ name: 'Sky 300', value: '#7dd3fc' } { name: 'Sky 300', value: '#7dd3fc' }
]; ];
onAction(type: MenuAction['type']): void { onAction(type: MenuAction['type'], payload?: any): void {
if (type === 'copy') { if (type === 'copy') {
// Copy block to clipboard // Copy block to clipboard
this.copyBlockToClipboard(); this.copyBlockToClipboard();
} else { } else {
// Emit action for parent to handle (including comment) // Emit action for parent to handle (including ratios/alignment payload)
this.action.emit({ type }); this.action.emit({ type, payload });
} }
this.close.emit(); this.close.emit();
} }
onAlignImage(alignment: 'left' | 'center' | 'right'): void {
this.action.emit({ type: 'imageAlignment', payload: { alignment } });
this.close.emit();
}
onImageDefaultSize(): void {
this.action.emit({ type: 'imageDefaultSize' });
this.close.emit();
}
private copyBlockToClipboard(): void { private copyBlockToClipboard(): void {
// Store in service for paste // Store in service for paste
this.clipboardData = JSON.parse(JSON.stringify(this.block)); this.clipboardData = JSON.parse(JSON.stringify(this.block));

View File

@ -9,6 +9,8 @@ import { CommentStoreService } from '../../services/comment-store.service';
import { Overlay, OverlayModule, OverlayRef } from '@angular/cdk/overlay'; import { Overlay, OverlayModule, OverlayRef } from '@angular/cdk/overlay';
import { ComponentPortal, PortalModule } from '@angular/cdk/portal'; import { ComponentPortal, PortalModule } from '@angular/cdk/portal';
import { BlockCommentComposerComponent } from '../comment/block-comment-composer.component'; import { BlockCommentComposerComponent } from '../comment/block-comment-composer.component';
import { ImageInfoModalComponent } from '../image/image-info-modal.component';
import { ImageCaptionModalComponent } from '../image/image-caption-modal.component';
import { BlockInitialMenuComponent, BlockMenuAction } from './block-initial-menu.component'; import { BlockInitialMenuComponent, BlockMenuAction } from './block-initial-menu.component';
// Import block components // Import block components
@ -243,7 +245,9 @@ export class BlockHostComponent implements OnDestroy {
private readonly overlay = inject(Overlay); private readonly overlay = inject(Overlay);
private readonly host = inject(ElementRef<HTMLElement>); private readonly host = inject(ElementRef<HTMLElement>);
private commentRef?: OverlayRef; private commentRef?: OverlayRef;
private commentSub?: { unsubscribe: () => void } | null = null; private commentSub?: OverlayRef | { unsubscribe: () => void } | null = null;
private imageInfoRef?: OverlayRef;
private imageCaptionRef?: OverlayRef;
readonly isActive = signal(false); readonly isActive = signal(false);
readonly menuVisible = signal(false); readonly menuVisible = signal(false);
@ -356,9 +360,14 @@ export class BlockHostComponent implements OnDestroy {
return; return;
} else { } else {
// Dropping in the gap BETWEEN columns - insert as new column // Dropping in the gap BETWEEN columns - insert as new column
const columnsContainerEl = columnsBlockEl.querySelector('[class*="columns"]'); let columnsContainerEl = columnsBlockEl.querySelector('[data-column-id]') as HTMLElement | null;
columnsContainerEl = (columnsContainerEl?.parentElement as HTMLElement) || columnsContainerEl;
if (!columnsContainerEl) {
// Fallback: use the block element itself
columnsContainerEl = columnsBlockEl as HTMLElement;
}
if (columnsContainerEl) { if (columnsContainerEl) {
const containerRect = columnsContainerEl.getBoundingClientRect(); const containerRect = (columnsContainerEl as HTMLElement).getBoundingClientRect();
const props = columnsBlock.props as any; const props = columnsBlock.props as any;
const columns = [...(props.columns || [])]; const columns = [...(props.columns || [])];
@ -403,91 +412,44 @@ export class BlockHostComponent implements OnDestroy {
} }
} }
} }
}
}
}
const blocks = this.documentService.blocks();
// Handle column creation/addition
if (mode === 'column-left' || mode === 'column-right') {
const targetBlock = blocks[to];
if (!targetBlock) return;
// Create copy of dragged block
const draggedBlockCopy = JSON.parse(JSON.stringify(this.block));
// Find the target block's position
const targetIndex = blocks.findIndex(b => b.id === targetBlock.id);
// Check if target is already a columns block
if (targetBlock.type === 'columns') {
// Add new column to existing columns block
const columnsProps = targetBlock.props as any;
const currentColumns = columnsProps.columns || [];
const newColumnWidth = 100 / (currentColumns.length + 1);
// Recalculate existing column widths
const updatedColumns = currentColumns.map((col: any) => ({
...col,
width: newColumnWidth
}));
// Add new column
const newColumn = {
id: this.generateId(),
blocks: [draggedBlockCopy],
width: newColumnWidth
};
if (mode === 'column-left') {
updatedColumns.unshift(newColumn);
} else { } else {
updatedColumns.push(newColumn); // Not a columns block: detect lateral drop on a normal block to create a 2-column layout
const targetWrapper = target.closest('.block-wrapper[data-block-id]') as HTMLElement | null;
const targetBlockId = targetWrapper?.getAttribute('data-block-id') || null;
if (targetWrapper && targetBlockId && targetBlockId !== this.block.id) {
const contentEl = targetWrapper.querySelector('.block-content') as HTMLElement | null;
const rect = (contentEl || targetWrapper).getBoundingClientRect();
const relX = e.clientX - rect.left;
const sideThreshold = Math.min(80, Math.max(40, rect.width * 0.15)); // 15% width, clamped 40-80px
const onLeft = relX <= sideThreshold;
const onRight = relX >= rect.width - sideThreshold;
if (onLeft || onRight) {
const all = this.documentService.blocks();
const targetIndex = all.findIndex(b => b.id === targetBlockId);
if (targetIndex >= 0) {
// Prepare new columns block with dragged + target in the right order
const draggedCopy = JSON.parse(JSON.stringify(this.block));
const targetBlock = all[targetIndex];
const columns = onLeft
? [ { id: this.generateId(), blocks: [draggedCopy], width: 50 }, { id: this.generateId(), blocks: [JSON.parse(JSON.stringify(targetBlock))], width: 50 } ]
: [ { id: this.generateId(), blocks: [JSON.parse(JSON.stringify(targetBlock))], width: 50 }, { id: this.generateId(), blocks: [draggedCopy], width: 50 } ];
const newColumnsBlock = this.documentService.createBlock('columns', { columns });
// Insert new columns block before the target, then delete old dragged + target blocks
const beforeId = targetIndex > 0 ? all[targetIndex - 1].id : null;
this.documentService.insertBlock(beforeId, newColumnsBlock);
this.documentService.deleteBlock(targetBlockId);
this.documentService.deleteBlock(this.block.id);
this.selectionService.setActive(draggedCopy.id);
return;
}
}
}
} }
// Update the columns block
this.documentService.updateBlockProps(targetBlock.id, {
columns: updatedColumns
});
// Delete dragged block
this.documentService.deleteBlock(this.block.id);
this.selectionService.setActive(targetBlock.id);
return;
} }
// Create new columns block with two columns
const targetBlockCopy = JSON.parse(JSON.stringify(targetBlock));
const newColumnsBlock = this.documentService.createBlock('columns', {
columns: mode === 'column-left'
? [
{ id: this.generateId(), blocks: [draggedBlockCopy], width: 50 },
{ id: this.generateId(), blocks: [targetBlockCopy], width: 50 }
]
: [
{ id: this.generateId(), blocks: [targetBlockCopy], width: 50 },
{ id: this.generateId(), blocks: [draggedBlockCopy], width: 50 }
]
});
// Delete both blocks
this.documentService.deleteBlock(this.block.id);
this.documentService.deleteBlock(targetBlock.id);
// Insert columns block at target position
if (targetIndex > 0) {
const beforeBlockId = this.documentService.blocks()[targetIndex - 1]?.id || null;
this.documentService.insertBlock(beforeBlockId, newColumnsBlock);
} else {
this.documentService.insertBlock(null, newColumnsBlock);
}
this.selectionService.setActive(newColumnsBlock.id);
return;
} }
// Handle regular line move // Handle regular line move
const blocks = this.documentService.blocks();
let toIndex = to; let toIndex = to;
if (toIndex > from) toIndex = toIndex - 1; if (toIndex > from) toIndex = toIndex - 1;
if (toIndex < 0) toIndex = 0; if (toIndex < 0) toIndex = 0;
@ -740,16 +702,8 @@ export class BlockHostComponent implements OnDestroy {
} }
break; break;
case 'addCaption': case 'addCaption':
// For Table/Image blocks - add or edit caption
if (this.block.type === 'table' || this.block.type === 'image') { if (this.block.type === 'table' || this.block.type === 'image') {
const currentCaption = (this.block.props as any)?.caption || ''; this.openCaptionModal();
const caption = prompt(`Enter ${this.block.type} caption:`, currentCaption);
if (caption !== null) {
this.documentService.updateBlockProps(this.block.id, {
...this.block.props,
caption: caption.trim() || undefined
});
}
} }
break; break;
case 'tableLayout': case 'tableLayout':
@ -843,19 +797,29 @@ export class BlockHostComponent implements OnDestroy {
case 'imageAspectRatio': case 'imageAspectRatio':
if (this.block.type === 'image') { if (this.block.type === 'image') {
const { ratio } = action.payload || {}; const { ratio } = action.payload || {};
this.documentService.updateBlockProps(this.block.id, { const patch: any = { ...this.block.props, aspectRatio: ratio };
...this.block.props, if (ratio && ratio !== 'free') {
aspectRatio: ratio patch.height = undefined; // let CSS aspect-ratio compute height from width
}); }
this.documentService.updateBlockProps(this.block.id, patch);
} }
break; break;
case 'imageAlignment': case 'imageAlignment':
if (this.block.type === 'image') { if (this.block.type === 'image') {
const { alignment } = action.payload || {}; const { alignment } = action.payload || {};
const patch: any = { ...this.block.props, alignment };
if (alignment === 'full') { patch.width = undefined; patch.height = undefined; }
this.documentService.updateBlockProps(this.block.id, patch);
}
break;
case 'imageDefaultSize':
if (this.block.type === 'image') {
this.documentService.updateBlockProps(this.block.id, { this.documentService.updateBlockProps(this.block.id, {
...this.block.props, ...this.block.props,
alignment width: undefined,
}); height: undefined,
aspectRatio: 'free'
} as any);
} }
break; break;
case 'imageReplace': case 'imageReplace':
@ -927,9 +891,7 @@ export class BlockHostComponent implements OnDestroy {
break; break;
case 'imageInfo': case 'imageInfo':
if (this.block.type === 'image') { if (this.block.type === 'image') {
const p: any = this.block.props || {}; this.openImageInfo();
const info = `URL: ${p.src}\nAlt: ${p.alt || ''}\nSize: ${p.width || '-'} x ${p.height || '-'} px\nAspect: ${p.aspectRatio || 'free'}\nAlignment: ${p.alignment || 'center'}\nRotation: ${p.rotation || 0}°`;
alert(info);
} }
break; break;
case 'comment': case 'comment':
@ -1000,8 +962,60 @@ export class BlockHostComponent implements OnDestroy {
this.commentRef.keydownEvents().subscribe((e) => { if ((e as KeyboardEvent).key === 'Escape') this.closeComments(); }); this.commentRef.keydownEvents().subscribe((e) => { if ((e as KeyboardEvent).key === 'Escape') this.closeComments(); });
} }
closeComments(): void { closeComments(): void {
if (this.commentSub) { try { this.commentSub.unsubscribe(); } catch {} this.commentSub = null; } if (this.commentSub) { try { (this.commentSub as any).unsubscribe?.(); } catch {} this.commentSub = null; }
if (this.commentRef) { this.commentRef.dispose(); this.commentRef = undefined; } if (this.commentRef) { this.commentRef.dispose(); this.commentRef = undefined; }
} }
private openImageInfo(): void {
this.closeImageInfo();
const anchor = this.host.nativeElement.querySelector(`[data-block-id="${this.block.id}"]`) as HTMLElement || this.host.nativeElement;
const pos = this.overlay.position().flexibleConnectedTo(anchor).withPositions([
{ originX: 'center', originY: 'center', overlayX: 'center', overlayY: 'center' }
]);
this.imageInfoRef = this.overlay.create({ hasBackdrop: true, backdropClass: 'cdk-overlay-dark-backdrop', positionStrategy: pos, panelClass: 'nimbus-menu-panel' });
const portal = new ComponentPortal(ImageInfoModalComponent);
const ref = this.imageInfoRef.attach(portal);
const p: any = this.block.props || {};
ref.instance.src = p.src || '';
ref.instance.width = p.width;
ref.instance.height = p.height;
ref.instance.aspect = p.aspectRatio;
ref.instance.alignment = p.alignment;
ref.instance.rotation = p.rotation;
const sub = ref.instance.close.subscribe(() => this.closeImageInfo());
this.imageInfoRef.backdropClick().subscribe(() => this.closeImageInfo());
this.imageInfoRef.keydownEvents().subscribe((e) => { if ((e as KeyboardEvent).key === 'Escape') this.closeImageInfo(); });
this.commentSub = { unsubscribe: () => { try { sub.unsubscribe(); } catch {} } } as any;
}
private closeImageInfo(): void {
if (this.imageInfoRef) { this.imageInfoRef.dispose(); this.imageInfoRef = undefined; }
}
private openCaptionModal(): void {
this.closeCaptionModal();
const anchor = this.host.nativeElement.querySelector(`[data-block-id="${this.block.id}"]`) as HTMLElement || this.host.nativeElement;
const pos = this.overlay.position().flexibleConnectedTo(anchor).withPositions([
{ originX: 'center', originY: 'center', overlayX: 'center', overlayY: 'center' }
]);
this.imageCaptionRef = this.overlay.create({ hasBackdrop: true, backdropClass: 'cdk-overlay-dark-backdrop', positionStrategy: pos, panelClass: 'nimbus-menu-panel' });
const portal = new ComponentPortal(ImageCaptionModalComponent);
const ref = this.imageCaptionRef.attach(portal);
const currentCaption = (this.block.props as any)?.caption || '';
ref.instance.caption = currentCaption;
ref.instance.title = this.block.type === 'table' ? 'Table caption' : 'Image caption';
const onSave = ref.instance.save.subscribe((caption: string) => {
this.documentService.updateBlockProps(this.block.id, {
...this.block.props,
caption: (caption || '').trim() || undefined
} as any);
this.closeCaptionModal();
});
const onCancel = ref.instance.cancel.subscribe(() => this.closeCaptionModal());
this.imageCaptionRef.backdropClick().subscribe(() => this.closeCaptionModal());
this.imageCaptionRef.keydownEvents().subscribe((e) => { if ((e as KeyboardEvent).key === 'Escape') this.closeCaptionModal(); });
this.commentSub = { unsubscribe: () => { try { onSave.unsubscribe(); onCancel.unsubscribe(); } catch {} } } as any;
}
private closeCaptionModal(): void {
if (this.imageCaptionRef) { this.imageCaptionRef.dispose(); this.imageCaptionRef = undefined; }
}
ngOnDestroy(): void { this.closeComments(); } ngOnDestroy(): void { this.closeComments(); }
} }

View File

@ -416,6 +416,44 @@ export class ColumnsBlockComponent {
this.duplicateBlockInColumns(block.id); this.duplicateBlockInColumns(block.id);
} }
// Handle add action inside columns
if (action.type === 'add') {
const pos = (action.payload || {}).position as 'above' | 'below' | 'left' | 'right' | undefined;
if (pos) {
if (pos === 'above' || pos === 'below') {
// Insert paragraph in same column before/after the selected block
const updatedColumns = this.props.columns.map((column) => {
const idx = column.blocks.findIndex(b => b.id === block.id);
if (idx === -1) return column;
const newBlock = this.documentService.createBlock('paragraph', { text: '' });
const newBlocks = [...column.blocks];
const insertAt = pos === 'above' ? idx : idx + 1;
newBlocks.splice(insertAt, 0, newBlock);
return { ...column, blocks: newBlocks };
});
this.update.emit({ columns: updatedColumns });
this.closeMenu();
return;
}
if (pos === 'left' || pos === 'right') {
// Add a new column on left/right with a new paragraph
const newParagraph = this.documentService.createBlock('paragraph', { text: '' });
const cols = [...this.props.columns];
const newWidth = 100 / (cols.length + 1);
const resized = cols.map(col => ({ ...col, width: newWidth }));
const insertion = { id: this.generateId(), blocks: [newParagraph], width: newWidth } as any;
const targetColIndex = resized.findIndex(c => c.blocks.some(b => b.id === block.id));
if (targetColIndex >= 0) {
if (pos === 'left') resized.splice(targetColIndex, 0, insertion);
else resized.splice(targetColIndex + 1, 0, insertion);
this.update.emit({ columns: resized });
this.closeMenu();
return;
}
}
}
}
this.closeMenu(); this.closeMenu();
} }

View File

@ -53,6 +53,40 @@ import { ImageUploadService } from '../../../services/image-upload.service';
<div class="absolute top-7 right-1 z-20 rounded-lg border border-neutral-300 bg-white text-gray-800 shadow-xl p-2 w-max" <div class="absolute top-7 right-1 z-20 rounded-lg border border-neutral-300 bg-white text-gray-800 shadow-xl p-2 w-max"
(mouseleave)="showQuick.set(false)" (mouseleave)="showQuick.set(false)"
(click)="$event.stopPropagation()"> (click)="$event.stopPropagation()">
<div class="text-[11px] font-semibold text-gray-500 mb-1">Align</div>
<div class="flex items-center gap-1 mb-2">
<button class="qa-icon-btn" title="Align left" (click)="setAlignment('left')">
<svg class="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6">
<rect x="3" y="5.5" width="18" height="13" rx="2" opacity="0.35"/>
<rect x="5" y="8" width="7" height="8" rx="1" fill="currentColor"/>
</svg>
</button>
<button class="qa-icon-btn" title="Align center" (click)="setAlignment('center')">
<svg class="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6">
<rect x="3" y="5.5" width="18" height="13" rx="2" opacity="0.35"/>
<rect x="8.5" y="8" width="7" height="8" rx="1" fill="currentColor"/>
</svg>
</button>
<button class="qa-icon-btn" title="Align right" (click)="setAlignment('right')">
<svg class="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6">
<rect x="3" y="5.5" width="18" height="13" rx="2" opacity="0.35"/>
<rect x="12" y="8" width="7" height="8" rx="1" fill="currentColor"/>
</svg>
</button>
<div class="w-px h-4 mx-1 bg-neutral-200"></div>
<button class="qa-icon-btn" title="Default size" (click)="defaultSize()">
<svg class="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6">
<rect x="3" y="5.5" width="18" height="13" rx="2" opacity="0.35"/>
<rect x="9" y="8" width="6" height="8" rx="1" fill="currentColor"/>
</svg>
</button>
<button class="qa-icon-btn" title="Full width" (click)="setAlignment('full')">
<svg class="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6">
<rect x="3" y="5.5" width="18" height="13" rx="2" opacity="0.35"/>
<rect x="4.5" y="8" width="15" height="8" rx="1" fill="currentColor"/>
</svg>
</button>
</div>
<div class="text-[11px] font-semibold text-gray-500 mb-1">Aspect</div> <div class="text-[11px] font-semibold text-gray-500 mb-1">Aspect</div>
<div class="flex items-center gap-1"> <div class="flex items-center gap-1">
<button class="qa-chip" [class.qa-chip-active]="isActive('free')" (click)="onAspect('free')">Free</button> <button class="qa-chip" [class.qa-chip-active]="isActive('free')" (click)="onAspect('free')">Free</button>
@ -69,14 +103,24 @@ import { ImageUploadService } from '../../../services/image-upload.service';
} }
@if (showHandles()) { @if (showHandles()) {
<div class="resize-handle corner top-left" (mousedown)="onResizeStart($event, 'nw')"></div> <div class="resize-handle corner top-left" (mousedown)="onResizeStart($event, 'nw')">
<div class="resize-handle corner top-right" (mousedown)="onResizeStart($event, 'ne')"></div> <svg class="rh-ico" viewBox="0 0 24 24" fill="currentColor"><path d="M6 10V6h4v2H8v2z"/><path d="M5 5h6v6H9V8H5z" opacity=".6"/></svg>
<div class="resize-handle corner bottom-left" (mousedown)="onResizeStart($event, 'sw')"></div> </div>
<div class="resize-handle corner bottom-right" (mousedown)="onResizeStart($event, 'se')"></div> <div class="resize-handle corner top-right" (mousedown)="onResizeStart($event, 'ne')">
<div class="resize-handle edge top" (mousedown)="onResizeStart($event, 'n')"></div> <svg class="rh-ico" viewBox="0 0 24 24" fill="currentColor"><path d="M14 6h4v4h-2V8h-2z"/><path d="M13 5h6v6h-2V7h-4z" opacity=".6"/></svg>
<div class="resize-handle edge bottom" (mousedown)="onResizeStart($event, 's')"></div> </div>
<div class="resize-handle edge left" (mousedown)="onResizeStart($event, 'w')"></div> <div class="resize-handle corner bottom-left" (mousedown)="onResizeStart($event, 'sw')">
<div class="resize-handle edge right" (mousedown)="onResizeStart($event, 'e')"></div> <svg class="rh-ico" viewBox="0 0 24 24" fill="currentColor"><path d="M6 14v4h4v-2H8v-2z"/><path d="M5 13h6v6H9v-4H5z" opacity=".6"/></svg>
</div>
<div class="resize-handle corner bottom-right" (mousedown)="onResizeStart($event, 'se')">
<svg class="rh-ico" viewBox="0 0 24 24" fill="currentColor"><path d="M18 18h-4v-2h2v-2h2z"/><path d="M19 19h-6v-6h2v4h4z" opacity=".6"/></svg>
</div>
<div class="resize-handle edge bottom" (mousedown)="onResizeStart($event, 's')">
<svg class="rh-ico" viewBox="0 0 24 24" fill="currentColor"><path d="M12 19l-3-3h2V8h2v8h2z"/></svg>
</div>
<div class="resize-handle edge right" (mousedown)="onResizeStart($event, 'e')">
<svg class="rh-ico" viewBox="0 0 24 24" fill="currentColor"><path d="M19 12l-3 3v-2H8v-2h8V9z"/></svg>
</div>
} }
@if (props.caption) { @if (props.caption) {
@ -127,27 +171,22 @@ import { ImageUploadService } from '../../../services/image-upload.service';
.resize-handle { .resize-handle {
position: absolute; position: absolute;
background: #ffffff; background: rgba(0,0,0,0.78);
border: 2px solid #9ca3af; /* gray-400 */ color: #e5e7eb;
border-radius: 50%; border: 1px solid rgba(255,255,255,0.25);
border-radius: 9999px;
cursor: pointer; cursor: pointer;
z-index: 10; z-index: 10;
transition: transform 0.2s; transition: transform 0.2s, background 0.2s;
} }
.resize-handle:hover { .resize-handle:hover {
transform: scale(1.2); transform: scale(1.2);
} }
.resize-handle.corner { .resize-handle.corner { width: 20px; height: 20px; display:flex; align-items:center; justify-content:center; }
width: 12px; .resize-handle.edge { width: 20px; height: 20px; display:flex; align-items:center; justify-content:center; }
height: 12px; .rh-ico { width: 12px; height: 12px; opacity: 0.9; }
}
.resize-handle.edge {
width: 10px;
height: 10px;
}
.resize-handle.top-left { .resize-handle.top-left {
top: -6px; top: -6px;
@ -243,6 +282,8 @@ import { ImageUploadService } from '../../../services/image-upload.service';
background: #ffffff; background: #ffffff;
} }
.qa-btn:hover { background: #f3f4f6; } .qa-btn:hover { background: #f3f4f6; }
.qa-icon-btn { width: 28px; height: 28px; display:flex; align-items:center; justify-content:center; border-radius: 6px; border: 1px solid #e5e7eb; background: #ffffff; }
.qa-icon-btn:hover { background: #f3f4f6; }
`] `]
}) })
export class ImageBlockComponent { export class ImageBlockComponent {
@ -394,11 +435,25 @@ export class ImageBlockComponent {
return (this.props.aspectRatio || 'free') === ratio; return (this.props.aspectRatio || 'free') === ratio;
} }
onAspect(ratio: string) { onAspect(ratio: string) {
this.update.emit({ ...this.props, aspectRatio: ratio }); const patch: any = { ...this.props, aspectRatio: ratio };
if (ratio && ratio !== 'free') {
patch.height = undefined;
}
this.update.emit(patch);
} }
onCrop() { onCrop() {
alert('Crop coming soon!'); alert('Crop coming soon!');
} }
setAlignment(a: 'left' | 'center' | 'right' | 'full') {
const patch: any = { ...this.props, alignment: a };
if (a === 'full') { patch.width = undefined; patch.height = undefined; }
this.update.emit(patch);
this.showQuick.set(false);
}
defaultSize() {
this.update.emit({ ...this.props, width: undefined, height: undefined, aspectRatio: 'free' } as any);
this.showQuick.set(false);
}
openSettings(ev: MouseEvent) { openSettings(ev: MouseEvent) {
ev.stopPropagation(); ev.stopPropagation();
const rect = (ev.currentTarget as HTMLElement).getBoundingClientRect(); const rect = (ev.currentTarget as HTMLElement).getBoundingClientRect();
@ -432,23 +487,24 @@ export class ImageBlockComponent {
let newWidth = this.startWidth; let newWidth = this.startWidth;
let newHeight = this.startHeight; let newHeight = this.startHeight;
const dir = this.resizeDirection;
const isCorner = dir === 'nw' || dir === 'ne' || dir === 'sw' || dir === 'se';
const hasFixedRatio = !!(this.props.aspectRatio && this.props.aspectRatio !== 'free');
// Calculer les nouvelles dimensions selon la direction // Compute tentative size per axis
if (this.resizeDirection.includes('e')) newWidth = this.startWidth + deltaX; if (dir.includes('e')) newWidth = this.startWidth + deltaX;
if (this.resizeDirection.includes('w')) newWidth = this.startWidth - deltaX; if (dir.includes('w')) newWidth = this.startWidth - deltaX;
if (this.resizeDirection.includes('s')) newHeight = this.startHeight + deltaY; if (dir.includes('s')) newHeight = this.startHeight + deltaY;
if (this.resizeDirection.includes('n')) newHeight = this.startHeight - deltaY; if (dir.includes('n')) newHeight = this.startHeight - deltaY;
// Limites min/max // Clamp
newWidth = Math.max(100, Math.min(1200, newWidth)); newWidth = Math.max(100, Math.min(1600, newWidth));
newHeight = Math.max(100, Math.min(1200, newHeight)); newHeight = Math.max(100, Math.min(1600, newHeight));
// Si aspect ratio défini, maintenir la proportion // Maintain aspect ratio for corner handles: if no fixed ratio, keep initial ratio
if (this.props.aspectRatio && this.props.aspectRatio !== 'free') { if (isCorner) {
const ratio = this.getAspectRatioValue(); const ratio = hasFixedRatio ? (this.getAspectRatioValue() || (this.startWidth / this.startHeight)) : (this.startWidth / this.startHeight);
if (ratio) { if (ratio > 0) newHeight = Math.round(newWidth / ratio);
newHeight = newWidth / ratio;
}
} }
this.update.emit({ this.update.emit({

View File

@ -0,0 +1,38 @@
import { Component, ChangeDetectionStrategy, EventEmitter, Input, Output } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
@Component({
selector: 'app-image-caption-modal',
standalone: true,
imports: [CommonModule, FormsModule],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<div class="fixed inset-0 z-[2000] flex items-center justify-center" (click)="onBackdrop($event)">
<div class="absolute inset-0 bg-black/50 backdrop-blur-[1px]"></div>
<div class="relative w-full max-w-md mx-3 rounded-2xl border shadow-xl p-5 md:p-6 z-[2001]"
[class.bg-white]="!isDark()" [class.border-slate-200]="!isDark()"
[class.bg-slate-900]="isDark()" [class.border-slate-700]="isDark()"
(click)="$event.stopPropagation()">
<h2 class="text-lg font-semibold mb-3">{{ title || 'Image caption' }}</h2>
<label class="text-sm block mb-2 text-slate-600 dark:text-slate-300">Caption</label>
<textarea class="w-full rounded-lg border px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 min-h-[90px]"
[class.border-slate-300]="!isDark()" [class.bg-white]="!isDark()"
[class.border-slate-600]="isDark()" [class.bg-slate-800]="isDark()"
[(ngModel)]="caption"></textarea>
<div class="mt-4 flex justify-end gap-2">
<button class="px-3 py-2 rounded border text-sm hover:bg-slate-100 dark:hover:bg-slate-700" (click)="cancel.emit()">Cancel</button>
<button class="px-3 py-2 rounded bg-blue-600 text-white text-sm hover:bg-blue-700" (click)="save.emit(caption)">Save</button>
</div>
</div>
</div>
`,
})
export class ImageCaptionModalComponent {
@Input() caption: string = '';
@Input() title: string = '';
@Output() save = new EventEmitter<string>();
@Output() cancel = new EventEmitter<void>();
isDark(): boolean { try { return document.documentElement.classList.contains('dark'); } catch { return false; } }
onBackdrop(e: MouseEvent) { if (e.target === e.currentTarget) this.cancel.emit(); }
}

View File

@ -0,0 +1,92 @@
import { Component, ChangeDetectionStrategy, EventEmitter, Input, Output } from '@angular/core';
import { CommonModule } from '@angular/common';
@Component({
selector: 'app-image-info-modal',
standalone: true,
imports: [CommonModule],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<div class="fixed inset-0 z-[2000] flex items-center justify-center" (click)="onBackdrop($event)">
<div class="absolute inset-0 bg-black/50 backdrop-blur-[1px]"></div>
<div class="relative w-full max-w-lg mx-3 rounded-2xl border shadow-xl p-5 md:p-6 z-[2001]"
[class.bg-white]="!isDark()" [class.border-slate-200]="!isDark()"
[class.bg-slate-900]="isDark()" [class.border-slate-700]="isDark()"
(click)="$event.stopPropagation()">
<button class="absolute top-3 right-3 w-8 h-8 rounded-full flex items-center justify-center hover:bg-slate-100 dark:hover:bg-slate-700"
(click)="close.emit()" aria-label="Close">
<svg class="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M18 6 6 18"/><path d="m6 6 12 12"/>
</svg>
</button>
<div class="flex items-start gap-3 mb-4">
<div class="w-10 h-10 rounded-xl flex items-center justify-center text-lg"
[class.bg-slate-100]="!isDark()" [class.bg-slate-800]="isDark()">🖼</div>
<div class="min-w-0">
<h2 class="text-lg md:text-xl font-semibold truncate">Image info</h2>
<div class="text-xs text-slate-500 dark:text-slate-400 break-all">{{ src }}</div>
</div>
</div>
<div class="text-sm grid grid-cols-2 gap-x-4 gap-y-2">
<div><span class="text-slate-500 dark:text-slate-400">Displayed size:</span> {{ width || 'auto' }} × {{ height || 'auto' }} px</div>
<div><span class="text-slate-500 dark:text-slate-400">Natural size:</span> {{ natW || '—' }} × {{ natH || '—' }} px</div>
<div><span class="text-slate-500 dark:text-slate-400">Aspect:</span> {{ aspect || 'free' }}</div>
<div><span class="text-slate-500 dark:text-slate-400">Alignment:</span> {{ alignment || 'center' }}</div>
<div><span class="text-slate-500 dark:text-slate-400">Rotation:</span> {{ rotation || 0 }}°</div>
<div><span class="text-slate-500 dark:text-slate-400">Type:</span> {{ typeHint }}</div>
</div>
<div class="mt-6 flex justify-end gap-2">
<button class="px-3 py-2 rounded border text-sm hover:bg-slate-100 dark:hover:bg-slate-700" (click)="openInNewTab()">Open in new tab</button>
<button class="px-3 py-2 rounded bg-blue-600 text-white text-sm hover:bg-blue-700" (click)="download()">Download</button>
</div>
</div>
</div>
`,
})
export class ImageInfoModalComponent {
@Input() src: string = '';
@Input() width: number | undefined;
@Input() height: number | undefined;
@Input() aspect: string | undefined;
@Input() alignment: string | undefined;
@Input() rotation: number | undefined;
@Output() close = new EventEmitter<void>();
natW: number | null = null;
natH: number | null = null;
ngOnInit() {
if (this.src) {
const img = new Image();
img.onload = () => {
this.natW = img.naturalWidth;
this.natH = img.naturalHeight;
};
img.src = this.src;
}
}
get typeHint(): string {
const s = (this.src || '').toLowerCase();
if (s.endsWith('.png')) return 'PNG';
if (s.endsWith('.jpg') || s.endsWith('.jpeg')) return 'JPEG';
if (s.endsWith('.gif')) return 'GIF';
if (s.endsWith('.webp')) return 'WEBP';
return 'Image';
}
isDark(): boolean { try { return document.documentElement.classList.contains('dark'); } catch { return false; } }
onBackdrop(e: MouseEvent) { if (e.target === e.currentTarget) this.close.emit(); }
openInNewTab() { if (this.src) window.open(this.src, '_blank', 'noopener'); }
download() {
if (!this.src) return;
const a = document.createElement('a');
a.href = this.src;
a.download = this.src.split('/').pop() || 'image';
document.body.appendChild(a);
a.click();
a.remove();
}
}

View File

@ -181,6 +181,8 @@
}, },
"active": "aaf62e01f34df49b", "active": "aaf62e01f34df49b",
"lastOpenFiles": [ "lastOpenFiles": [
"attachments/nimbus/2025/1111/img-pasted-20251111-120357-ke3ao3.png",
"attachments/nimbus/2025/1111",
"attachments/nimbus/2025/1110/img-bridger-tower-vejbbvtdgcm-unsplash-jpg-20251110-175132-92f6k8.png", "attachments/nimbus/2025/1110/img-bridger-tower-vejbbvtdgcm-unsplash-jpg-20251110-175132-92f6k8.png",
"attachments/nimbus/2025/1110/img-image_1-png-20251110-154537-gaoaou.png", "attachments/nimbus/2025/1110/img-image_1-png-20251110-154537-gaoaou.png",
"attachments/nimbus/2025/1110/img-logo_obsiviewer-png-20251110-151755-r8r5qq.png", "attachments/nimbus/2025/1110/img-logo_obsiviewer-png-20251110-151755-r8r5qq.png",
@ -218,7 +220,6 @@
"big/note_497.md.bak", "big/note_497.md.bak",
"big/note_498.md.bak", "big/note_498.md.bak",
"big/note_495.md.bak", "big/note_495.md.bak",
"big/note_496.md.bak",
"mixe/Dessin-02.png", "mixe/Dessin-02.png",
"Dessin-02.png", "Dessin-02.png",
"mixe/Claude_ObsiViewer_V1.png", "mixe/Claude_ObsiViewer_V1.png",
@ -226,7 +227,6 @@
"Drawing-20251028-1452.png", "Drawing-20251028-1452.png",
"dessin.svg", "dessin.svg",
"dessin.png", "dessin.png",
"dessin_05.svg",
"Untitled.canvas" "Untitled.canvas"
] ]
} }

Binary file not shown.

After

Width:  |  Height:  |  Size: 805 KiB