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:
parent
ee3085ce38
commit
386007d351
@ -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));
|
||||||
|
|||||||
@ -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(); }
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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,25 +487,26 @@ export class ImageBlockComponent {
|
|||||||
|
|
||||||
let newWidth = this.startWidth;
|
let newWidth = this.startWidth;
|
||||||
let newHeight = this.startHeight;
|
let newHeight = this.startHeight;
|
||||||
|
const dir = this.resizeDirection;
|
||||||
// Calculer les nouvelles dimensions selon la direction
|
const isCorner = dir === 'nw' || dir === 'ne' || dir === 'sw' || dir === 'se';
|
||||||
if (this.resizeDirection.includes('e')) newWidth = this.startWidth + deltaX;
|
const hasFixedRatio = !!(this.props.aspectRatio && this.props.aspectRatio !== 'free');
|
||||||
if (this.resizeDirection.includes('w')) newWidth = this.startWidth - deltaX;
|
|
||||||
if (this.resizeDirection.includes('s')) newHeight = this.startHeight + deltaY;
|
// Compute tentative size per axis
|
||||||
if (this.resizeDirection.includes('n')) newHeight = this.startHeight - deltaY;
|
if (dir.includes('e')) newWidth = this.startWidth + deltaX;
|
||||||
|
if (dir.includes('w')) newWidth = this.startWidth - deltaX;
|
||||||
// Limites min/max
|
if (dir.includes('s')) newHeight = this.startHeight + deltaY;
|
||||||
newWidth = Math.max(100, Math.min(1200, newWidth));
|
if (dir.includes('n')) newHeight = this.startHeight - deltaY;
|
||||||
newHeight = Math.max(100, Math.min(1200, newHeight));
|
|
||||||
|
// Clamp
|
||||||
// Si aspect ratio défini, maintenir la proportion
|
newWidth = Math.max(100, Math.min(1600, newWidth));
|
||||||
if (this.props.aspectRatio && this.props.aspectRatio !== 'free') {
|
newHeight = Math.max(100, Math.min(1600, newHeight));
|
||||||
const ratio = this.getAspectRatioValue();
|
|
||||||
if (ratio) {
|
// Maintain aspect ratio for corner handles: if no fixed ratio, keep initial ratio
|
||||||
newHeight = newWidth / ratio;
|
if (isCorner) {
|
||||||
}
|
const ratio = hasFixedRatio ? (this.getAspectRatioValue() || (this.startWidth / this.startHeight)) : (this.startWidth / this.startHeight);
|
||||||
|
if (ratio > 0) newHeight = Math.round(newWidth / ratio);
|
||||||
}
|
}
|
||||||
|
|
||||||
this.update.emit({
|
this.update.emit({
|
||||||
...this.props,
|
...this.props,
|
||||||
width: Math.round(newWidth),
|
width: Math.round(newWidth),
|
||||||
|
|||||||
@ -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(); }
|
||||||
|
}
|
||||||
@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
4
vault/.obsidian/workspace.json
vendored
4
vault/.obsidian/workspace.json
vendored
@ -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 |
Loading…
x
Reference in New Issue
Block a user