refactor: compact block context menu styling and improve visual density

- Reduced menu width from 240px to 220px and decreased padding throughout for more compact layout
- Scaled down button sizes (p-2 → p-1.5), gaps (gap-3 → gap-2.5), and font sizes (text-base → text-sm)
- Shrunk color picker swatches from 7x7 to 5x5 with thinner ring borders for cleaner appearance
- Tightened spacing in toolbars, submenus, and all menu sections while maintaining usability
- Added opacity property binding
This commit is contained in:
Bruno Charest 2025-11-15 18:13:24 -05:00
parent 9887be548e
commit ba86bd4b91
13 changed files with 1358 additions and 316 deletions

View File

@ -3,6 +3,7 @@ import { CommonModule } from '@angular/common';
import { Block, BlockType } from '../../core/models/block.model'; import { Block, BlockType } from '../../core/models/block.model';
import { DocumentService } from '../../services/document.service'; import { DocumentService } from '../../services/document.service';
import { CodeThemeService } from '../../services/code-theme.service'; import { CodeThemeService } from '../../services/code-theme.service';
import { BlockMenuStylingService } from '../../services/block-menu-styling.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' | 'imageDefaultSize' | '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';
@ -17,7 +18,7 @@ export interface MenuAction {
<div <div
*ngIf="visible" *ngIf="visible"
#menu #menu
class="ctx fixed min-w-[240px] py-2" class="ctx fixed min-w-[220px] py-1" [style.opacity]="opacity"
[style.left.px]="left" [style.left.px]="left"
[style.top.px]="top" [style.top.px]="top"
role="menu" role="menu"
@ -26,11 +27,11 @@ export interface MenuAction {
(contextmenu)="$event.preventDefault()" (contextmenu)="$event.preventDefault()"
> >
<!-- Alignment toolbar (image has dedicated icons + size buttons). Non-image keeps generic align + indent. --> <!-- 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-0.5 px-2 py-1.5 border-b border-border">
@if (block.type === 'image') { @if (block.type === 'image') {
<!-- Image alignment: Left / Center / Right (custom icons like in ref) --> <!-- Image alignment: Left / Center / Right (custom icons like in ref) -->
<button <button
class="p-2 rounded hover:bg-surface2 transition" class="p-1.5 rounded hover:bg-surface2 transition"
title="Align left" title="Align left"
(click)="onAlignImage('left')" (click)="onAlignImage('left')"
> >
@ -40,7 +41,7 @@ export interface MenuAction {
</svg> </svg>
</button> </button>
<button <button
class="p-2 rounded hover:bg-surface2 transition" class="p-1.5 rounded hover:bg-surface2 transition"
title="Align center" title="Align center"
(click)="onAlignImage('center')" (click)="onAlignImage('center')"
> >
@ -50,7 +51,7 @@ export interface MenuAction {
</svg> </svg>
</button> </button>
<button <button
class="p-2 rounded hover:bg-surface2 transition" class="p-1.5 rounded hover:bg-surface2 transition"
title="Align right" title="Align right"
(click)="onAlignImage('right')" (click)="onAlignImage('right')"
> >
@ -62,7 +63,7 @@ export interface MenuAction {
<div class="w-px h-5 mx-1 bg-border"></div> <div class="w-px h-5 mx-1 bg-border"></div>
<!-- Default size and Full width --> <!-- Default size and Full width -->
<button <button
class="p-2 rounded hover:bg-surface2 transition" class="p-1.5 rounded hover:bg-surface2 transition"
title="Default size" title="Default size"
(click)="onImageDefaultSize()" (click)="onImageDefaultSize()"
> >
@ -72,7 +73,7 @@ export interface MenuAction {
</svg> </svg>
</button> </button>
<button <button
class="p-2 rounded hover:bg-surface2 transition" class="p-1.5 rounded hover:bg-surface2 transition"
title="Full width" title="Full width"
(click)="onAction('imageAlignment', { alignment: 'full' })" (click)="onAction('imageAlignment', { alignment: 'full' })"
> >
@ -84,7 +85,7 @@ export interface MenuAction {
} @else { } @else {
<button <button
*ngFor="let align of alignments" *ngFor="let align of alignments"
class="p-2 rounded hover:bg-surface2 transition" class="p-1.5 rounded hover:bg-surface2 transition"
[title]="align.label" [title]="align.label"
(click)="onAlign(align.value)" (click)="onAlign(align.value)"
> >
@ -94,7 +95,7 @@ export interface MenuAction {
</button> </button>
<div class="w-px h-5 mx-1 bg-border"></div> <div class="w-px h-5 mx-1 bg-border"></div>
<button <button
class="p-2 rounded hover:bg-surface2 transition" class="p-1.5 rounded hover:bg-surface2 transition"
title="Increase indent" title="Increase indent"
(click)="onIndent(1)" (click)="onIndent(1)"
> >
@ -106,7 +107,7 @@ export interface MenuAction {
</svg> </svg>
</button> </button>
<button <button
class="p-2 rounded hover:bg-surface2 transition" class="p-1.5 rounded hover:bg-surface2 transition"
title="Decrease indent" title="Decrease indent"
(click)="onIndent(-1)" (click)="onIndent(-1)"
> >
@ -122,7 +123,7 @@ export interface MenuAction {
<!-- Image quick ratios row (top, only for image) --> <!-- Image quick ratios row (top, only for image) -->
@if (block.type === 'image') { @if (block.type === 'image') {
<div class="flex items-center gap-2 px-3 py-2 border-b border-border"> <div class="flex items-center gap-1.5 px-2 py-1.5 border-b border-border">
<div class="text-xs text-text-muted mr-1">Aspect</div> <div class="text-xs text-text-muted mr-1">Aspect</div>
<button class="px-2 py-1 text-xs rounded border border-border hover:bg-surface2" <button class="px-2 py-1 text-xs rounded border border-border hover:bg-surface2"
[class.bg-primary/10]="isActiveAspectRatio('free')" [class.bg-primary/10]="isActiveAspectRatio('free')"
@ -145,7 +146,7 @@ export interface MenuAction {
<!-- Main menu items --> <!-- Main menu items -->
<div class="py-1"> <div class="py-1">
<button <button
class="w-full text-left px-4 py-2 hover:bg-surface2 transition flex items-center gap-3" class="w-full text-left px-3 py-1.5 hover:bg-surface2 transition flex items-center gap-2.5 text-sm"
(click)="onAction('comment')" (click)="onAction('comment')"
> >
<span class="text-base">💬</span> <span class="text-base">💬</span>
@ -154,12 +155,12 @@ export interface MenuAction {
<div class="relative"> <div class="relative">
<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-3 py-1.5 hover:bg-surface2 transition flex items-center gap-2.5 justify-between text-sm"
[attr.data-submenu]="'add'" [attr.data-submenu]="'add'"
(mouseenter)="onOpenSubmenu($event, 'add')" (mouseenter)="onOpenSubmenu($event, 'add')"
(click)="toggleSubmenu($event, 'add')" (click)="toggleSubmenu($event, 'add')"
> >
<div class="flex items-center gap-3"> <div class="flex items-center gap-2.5">
<span class="text-base"></span> <span class="text-base"></span>
<span>Add block</span> <span>Add block</span>
</div> </div>
@ -185,12 +186,12 @@ export interface MenuAction {
@if (convertOptions.length) { @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-3 py-1.5 hover:bg-surface2 transition flex items-center gap-2.5 justify-between text-sm"
[attr.data-submenu]="'convert'" [attr.data-submenu]="'convert'"
(mouseenter)="onOpenSubmenu($event, 'convert')" (mouseenter)="onOpenSubmenu($event, 'convert')"
(click)="toggleSubmenu($event, 'convert')" (click)="toggleSubmenu($event, 'convert')"
> >
<div class="flex items-center gap-3"> <div class="flex items-center gap-2.5">
<span class="text-base">🔄</span> <span class="text-base">🔄</span>
<span>Convert to</span> <span>Convert to</span>
</div> </div>
@ -200,12 +201,12 @@ export interface MenuAction {
<div class="relative"> <div class="relative">
<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-3 py-1.5 hover:bg-surface2 transition flex items-center gap-2.5 justify-between text-sm"
[attr.data-submenu]="'background'" [attr.data-submenu]="'background'"
(mouseenter)="onOpenSubmenu($event, 'background')" (mouseenter)="onOpenSubmenu($event, 'background')"
(click)="toggleSubmenu($event, 'background')" (click)="toggleSubmenu($event, 'background')"
> >
<div class="flex items-center gap-3"> <div class="flex items-center gap-2.5">
<span class="text-base">🎨</span> <span class="text-base">🎨</span>
<span>Background color</span> <span>Background color</span>
</div> </div>
@ -215,19 +216,19 @@ export interface MenuAction {
<!-- Background color submenu (anchored to row, no gap) --> <!-- Background color submenu (anchored to row, no gap) -->
<div <div
*ngIf="showSubmenu === 'background'" *ngIf="showSubmenu === 'background'"
class="bg-surface1 border border-border rounded-lg shadow-xl p-3 w-[240px] z-50" class="bg-surface1 border border-border rounded-lg shadow-xl p-2 w-[200px] z-50"
[attr.data-submenu-panel]="'background'" [attr.data-submenu-panel]="'background'"
[ngStyle]="submenuStyle['background']" [ngStyle]="submenuStyle['background']"
(mouseenter)="onColorMenuEnter('background'); keepSubmenuOpen('background')" (mouseenter)="onColorMenuEnter('background'); keepSubmenuOpen('background')"
(mouseleave)="onColorMenuLeave('background'); closeSubmenu()" (mouseleave)="onColorMenuLeave('background'); closeSubmenu()"
> >
<div class="grid grid-cols-5 gap-3"> <div class="grid grid-cols-5 gap-2">
<button <button
*ngFor="let color of backgroundColors" *ngFor="let color of backgroundColors"
class="w-7 h-7 rounded-full ring-2 transition" class="w-5 h-5 rounded-full ring-1 transition hover:ring-2"
[style.backgroundColor]="color.value" [style.backgroundColor]="color.value"
[attr.title]="color.name" [attr.title]="color.name"
[ngClass]="{ 'ring-primary': isActiveBackgroundColor(color.value), 'ring-transparent hover:ring-primary': !isActiveBackgroundColor(color.value) }" [ngClass]="{ 'ring-primary ring-2': isActiveBackgroundColor(color.value), 'ring-border hover:ring-primary': !isActiveBackgroundColor(color.value) }"
(mouseenter)="onColorHover('background', color.value)" (mouseenter)="onColorHover('background', color.value)"
(click)="onBackgroundColor(color.value); onColorConfirm('background', color.value)" (click)="onBackgroundColor(color.value); onColorConfirm('background', color.value)"
></button> ></button>
@ -238,11 +239,11 @@ export interface MenuAction {
<!-- Border color (Hint blocks only) --> <!-- Border color (Hint blocks only) -->
<div class="relative" *ngIf="block.type === 'hint'"> <div class="relative" *ngIf="block.type === 'hint'">
<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-3 py-1.5 hover:bg-surface2 transition flex items-center gap-2.5 justify-between text-sm"
(mouseenter)="onOpenSubmenu($event, 'borderColor')" (mouseenter)="onOpenSubmenu($event, 'borderColor')"
(click)="toggleSubmenu($event, 'borderColor')" (click)="toggleSubmenu($event, 'borderColor')"
> >
<div class="flex items-center gap-3"> <div class="flex items-center gap-2.5">
<span class="text-base">🎨</span> <span class="text-base">🎨</span>
<span>Border color</span> <span>Border color</span>
</div> </div>
@ -252,19 +253,19 @@ export interface MenuAction {
<!-- Border color submenu --> <!-- Border color submenu -->
<div <div
*ngIf="showSubmenu === 'borderColor'" *ngIf="showSubmenu === 'borderColor'"
class="bg-surface1 border border-border rounded-lg shadow-xl p-3 w-[240px] z-50" class="bg-surface1 border border-border rounded-lg shadow-xl p-2 w-[200px] z-50"
[attr.data-submenu-panel]="'borderColor'" [attr.data-submenu-panel]="'borderColor'"
[ngStyle]="submenuStyle['borderColor']" [ngStyle]="submenuStyle['borderColor']"
(mouseenter)="onColorMenuEnter('borderColor'); keepSubmenuOpen('borderColor')" (mouseenter)="onColorMenuEnter('borderColor'); keepSubmenuOpen('borderColor')"
(mouseleave)="onColorMenuLeave('borderColor'); closeSubmenu()" (mouseleave)="onColorMenuLeave('borderColor'); closeSubmenu()"
> >
<div class="grid grid-cols-5 gap-3"> <div class="grid grid-cols-5 gap-2">
<button <button
*ngFor="let color of backgroundColors" *ngFor="let color of backgroundColors"
class="w-7 h-7 rounded-full ring-2 transition" class="w-5 h-5 rounded-full ring-1 transition hover:ring-2"
[style.backgroundColor]="color.value" [style.backgroundColor]="color.value"
[attr.title]="color.name" [attr.title]="color.name"
[ngClass]="{ 'ring-primary': isActiveBorderColor(color.value), 'ring-transparent hover:ring-primary': !isActiveBorderColor(color.value) }" [ngClass]="{ 'ring-primary ring-2': isActiveBorderColor(color.value), 'ring-border hover:ring-primary': !isActiveBorderColor(color.value) }"
(mouseenter)="onColorHover('borderColor', color.value)" (mouseenter)="onColorHover('borderColor', color.value)"
(click)="onBorderColor(color.value); onColorConfirm('borderColor', color.value)" (click)="onBorderColor(color.value); onColorConfirm('borderColor', color.value)"
></button> ></button>
@ -275,11 +276,11 @@ export interface MenuAction {
<!-- Line color (Quote and Hint blocks) --> <!-- Line color (Quote and Hint blocks) -->
<div class="relative" *ngIf="block.type === 'quote' || block.type === 'hint'"> <div class="relative" *ngIf="block.type === 'quote' || block.type === 'hint'">
<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-3 py-1.5 hover:bg-surface2 transition flex items-center gap-2.5 justify-between text-sm"
(mouseenter)="onOpenSubmenu($event, 'lineColor')" (mouseenter)="onOpenSubmenu($event, 'lineColor')"
(click)="toggleSubmenu($event, 'lineColor')" (click)="toggleSubmenu($event, 'lineColor')"
> >
<div class="flex items-center gap-3"> <div class="flex items-center gap-2.5">
<span class="text-base">🖌</span> <span class="text-base">🖌</span>
<span>Line color</span> <span>Line color</span>
</div> </div>
@ -289,19 +290,19 @@ export interface MenuAction {
<!-- Line color submenu --> <!-- Line color submenu -->
<div <div
*ngIf="showSubmenu === 'lineColor'" *ngIf="showSubmenu === 'lineColor'"
class="bg-surface1 border border-border rounded-lg shadow-xl p-3 w-[240px] z-50" class="bg-surface1 border border-border rounded-lg shadow-xl p-2 w-[200px] z-50"
[attr.data-submenu-panel]="'lineColor'" [attr.data-submenu-panel]="'lineColor'"
[ngStyle]="submenuStyle['lineColor']" [ngStyle]="submenuStyle['lineColor']"
(mouseenter)="onColorMenuEnter('lineColor'); keepSubmenuOpen('lineColor')" (mouseenter)="onColorMenuEnter('lineColor'); keepSubmenuOpen('lineColor')"
(mouseleave)="onColorMenuLeave('lineColor'); closeSubmenu()" (mouseleave)="onColorMenuLeave('lineColor'); closeSubmenu()"
> >
<div class="grid grid-cols-5 gap-3"> <div class="grid grid-cols-5 gap-2">
<button <button
*ngFor="let color of backgroundColors" *ngFor="let color of backgroundColors"
class="w-7 h-7 rounded-full ring-2 transition" class="w-5 h-5 rounded-full ring-1 transition hover:ring-2"
[style.backgroundColor]="color.value" [style.backgroundColor]="color.value"
[attr.title]="color.name" [attr.title]="color.name"
[ngClass]="{ 'ring-primary': isActiveLineColor(color.value), 'ring-transparent hover:ring-primary': !isActiveLineColor(color.value) }" [ngClass]="{ 'ring-primary ring-2': isActiveLineColor(color.value), 'ring-border hover:ring-primary': !isActiveLineColor(color.value) }"
(mouseenter)="onColorHover('lineColor', color.value)" (mouseenter)="onColorHover('lineColor', color.value)"
(click)="onLineColor(color.value); onColorConfirm('lineColor', color.value)" (click)="onLineColor(color.value); onColorConfirm('lineColor', color.value)"
></button> ></button>
@ -314,12 +315,12 @@ export interface MenuAction {
<!-- Language submenu --> <!-- Language submenu -->
<div class="relative"> <div class="relative">
<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-3 py-1.5 hover:bg-surface2 transition flex items-center gap-2.5 justify-between text-sm"
[attr.data-submenu]="'codeLanguage'" [attr.data-submenu]="'codeLanguage'"
(mouseenter)="onOpenSubmenu($event, 'codeLanguage')" (mouseenter)="onOpenSubmenu($event, 'codeLanguage')"
(click)="toggleSubmenu($event, 'codeLanguage')" (click)="toggleSubmenu($event, 'codeLanguage')"
> >
<div class="flex items-center gap-3"> <div class="flex items-center gap-2.5">
<span class="text-base">🔤</span> <span class="text-base">🔤</span>
<span>Language</span> <span>Language</span>
</div> </div>
@ -350,12 +351,12 @@ export interface MenuAction {
<!-- Theme submenu --> <!-- Theme submenu -->
<div class="relative"> <div class="relative">
<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-3 py-1.5 hover:bg-surface2 transition flex items-center gap-2.5 justify-between text-sm"
[attr.data-submenu]="'codeTheme'" [attr.data-submenu]="'codeTheme'"
(mouseenter)="onOpenSubmenu($event, 'codeTheme')" (mouseenter)="onOpenSubmenu($event, 'codeTheme')"
(click)="toggleSubmenu($event, 'codeTheme')" (click)="toggleSubmenu($event, 'codeTheme')"
> >
<div class="flex items-center gap-3"> <div class="flex items-center gap-2.5">
<span class="text-base">🎨</span> <span class="text-base">🎨</span>
<span>Theme</span> <span>Theme</span>
</div> </div>
@ -385,7 +386,7 @@ export interface MenuAction {
<!-- Copy code --> <!-- Copy code -->
<button <button
class="w-full text-left px-4 py-2 hover:bg-surface2 transition flex items-center gap-3" class="w-full text-left px-3 py-1.5 hover:bg-surface2 transition flex items-center gap-2.5 text-sm"
(click)="onAction('copyCode')" (click)="onAction('copyCode')"
> >
<span class="text-base">📋</span> <span class="text-base">📋</span>
@ -394,7 +395,7 @@ export interface MenuAction {
<!-- Toggle wrap --> <!-- Toggle wrap -->
<button <button
class="w-full text-left px-4 py-2 hover:bg-surface2 transition flex items-center gap-3" class="w-full text-left px-3 py-1.5 hover:bg-surface2 transition flex items-center gap-2.5 text-sm"
(click)="onAction('toggleWrap')" (click)="onAction('toggleWrap')"
> >
<span class="text-base">{{ getCodeWrapIcon() }}</span> <span class="text-base">{{ getCodeWrapIcon() }}</span>
@ -403,7 +404,7 @@ export interface MenuAction {
<!-- Toggle line numbers --> <!-- Toggle line numbers -->
<button <button
class="w-full text-left px-4 py-2 hover:bg-surface2 transition flex items-center gap-3" class="w-full text-left px-3 py-1.5 hover:bg-surface2 transition flex items-center gap-2.5 text-sm"
(click)="onAction('toggleLineNumbers')" (click)="onAction('toggleLineNumbers')"
> >
<span class="text-base">{{ getCodeLineNumbersIcon() }}</span> <span class="text-base">{{ getCodeLineNumbersIcon() }}</span>
@ -415,7 +416,7 @@ export interface MenuAction {
@if (block.type === 'image') { @if (block.type === 'image') {
<!-- Add caption --> <!-- Add caption -->
<button <button
class="w-full text-left px-4 py-2 hover:bg-surface2 transition flex items-center gap-3" class="w-full text-left px-3 py-1.5 hover:bg-surface2 transition flex items-center gap-2.5 text-sm"
(click)="onAction('addCaption')" (click)="onAction('addCaption')"
> >
<span class="text-base">📝</span> <span class="text-base">📝</span>
@ -425,12 +426,12 @@ export interface MenuAction {
<!-- Aspect ratio submenu --> <!-- Aspect ratio submenu -->
<div class="relative"> <div class="relative">
<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-3 py-1.5 hover:bg-surface2 transition flex items-center gap-2.5 justify-between text-sm"
[attr.data-submenu]="'imageAspectRatio'" [attr.data-submenu]="'imageAspectRatio'"
(mouseenter)="onOpenSubmenu($event, 'imageAspectRatio')" (mouseenter)="onOpenSubmenu($event, 'imageAspectRatio')"
(click)="toggleSubmenu($event, 'imageAspectRatio')" (click)="toggleSubmenu($event, 'imageAspectRatio')"
> >
<div class="flex items-center gap-3"> <div class="flex items-center gap-2.5">
<span class="text-base">📐</span> <span class="text-base">📐</span>
<span>Aspect ratio</span> <span>Aspect ratio</span>
</div> </div>
@ -456,12 +457,12 @@ export interface MenuAction {
<!-- Alignment submenu --> <!-- Alignment submenu -->
<div class="relative"> <div class="relative">
<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-3 py-1.5 hover:bg-surface2 transition flex items-center gap-2.5 justify-between text-sm"
[attr.data-submenu]="'imageAlignment'" [attr.data-submenu]="'imageAlignment'"
(mouseenter)="onOpenSubmenu($event, 'imageAlignment')" (mouseenter)="onOpenSubmenu($event, 'imageAlignment')"
(click)="toggleSubmenu($event, 'imageAlignment')" (click)="toggleSubmenu($event, 'imageAlignment')"
> >
<div class="flex items-center gap-3"> <div class="flex items-center gap-2.5">
<span class="text-base"></span> <span class="text-base"></span>
<span>Alignment</span> <span>Alignment</span>
</div> </div>
@ -485,7 +486,7 @@ export interface MenuAction {
<!-- Replace image --> <!-- Replace image -->
<button <button
class="w-full text-left px-4 py-2 hover:bg-surface2 transition flex items-center gap-3" class="w-full text-left px-3 py-1.5 hover:bg-surface2 transition flex items-center gap-2.5 text-sm"
(click)="onAction('imageReplace')" (click)="onAction('imageReplace')"
> >
<span class="text-base">🖼</span> <span class="text-base">🖼</span>
@ -494,7 +495,7 @@ export interface MenuAction {
<!-- Rotate image --> <!-- Rotate image -->
<button <button
class="w-full text-left px-4 py-2 hover:bg-surface2 transition flex items-center gap-3" class="w-full text-left px-3 py-1.5 hover:bg-surface2 transition flex items-center gap-2.5 text-sm"
(click)="onAction('imageRotate')" (click)="onAction('imageRotate')"
> >
<span class="text-base">🔄</span> <span class="text-base">🔄</span>
@ -503,7 +504,7 @@ export interface MenuAction {
<!-- Set as preview --> <!-- Set as preview -->
<button <button
class="w-full text-left px-4 py-2 hover:bg-surface2 transition flex items-center gap-3" class="w-full text-left px-3 py-1.5 hover:bg-surface2 transition flex items-center gap-2.5 text-sm"
(click)="onAction('imageSetPreview')" (click)="onAction('imageSetPreview')"
> >
<span class="text-base"></span> <span class="text-base"></span>
@ -512,7 +513,7 @@ export interface MenuAction {
<!-- OCR --> <!-- OCR -->
<button <button
class="w-full text-left px-4 py-2 hover:bg-surface2 transition flex items-center gap-3" class="w-full text-left px-3 py-1.5 hover:bg-surface2 transition flex items-center gap-2.5 text-sm"
(click)="onAction('imageOCR')" (click)="onAction('imageOCR')"
> >
<span class="text-base">🧠</span> <span class="text-base">🧠</span>
@ -521,7 +522,7 @@ export interface MenuAction {
<!-- Download --> <!-- Download -->
<button <button
class="w-full text-left px-4 py-2 hover:bg-surface2 transition flex items-center gap-3" class="w-full text-left px-3 py-1.5 hover:bg-surface2 transition flex items-center gap-2.5 text-sm"
(click)="onAction('imageDownload')" (click)="onAction('imageDownload')"
> >
<span class="text-base"></span> <span class="text-base"></span>
@ -530,7 +531,7 @@ export interface MenuAction {
<!-- View full size --> <!-- View full size -->
<button <button
class="w-full text-left px-4 py-2 hover:bg-surface2 transition flex items-center gap-3" class="w-full text-left px-3 py-1.5 hover:bg-surface2 transition flex items-center gap-2.5 text-sm"
(click)="onAction('imageViewFull')" (click)="onAction('imageViewFull')"
> >
<span class="text-base">🔎</span> <span class="text-base">🔎</span>
@ -539,7 +540,7 @@ export interface MenuAction {
<!-- Open in new tab --> <!-- Open in new tab -->
<button <button
class="w-full text-left px-4 py-2 hover:bg-surface2 transition flex items-center gap-3" class="w-full text-left px-3 py-1.5 hover:bg-surface2 transition flex items-center gap-2.5 text-sm"
(click)="onAction('imageOpenTab')" (click)="onAction('imageOpenTab')"
> >
<span class="text-base">🪟</span> <span class="text-base">🪟</span>
@ -548,7 +549,7 @@ export interface MenuAction {
<!-- Image info --> <!-- Image info -->
<button <button
class="w-full text-left px-4 py-2 hover:bg-surface2 transition flex items-center gap-3" class="w-full text-left px-3 py-1.5 hover:bg-surface2 transition flex items-center gap-2.5 text-sm"
(click)="onAction('imageInfo')" (click)="onAction('imageInfo')"
> >
<span class="text-base"></span> <span class="text-base"></span>
@ -560,7 +561,7 @@ export interface MenuAction {
@if (block.type === 'table') { @if (block.type === 'table') {
<!-- Add caption --> <!-- Add caption -->
<button <button
class="w-full text-left px-4 py-2 hover:bg-surface2 transition flex items-center gap-3" class="w-full text-left px-3 py-1.5 hover:bg-surface2 transition flex items-center gap-2.5 text-sm"
(click)="onAction('addCaption')" (click)="onAction('addCaption')"
> >
<span class="text-base">📝</span> <span class="text-base">📝</span>
@ -570,12 +571,12 @@ export interface MenuAction {
<!-- Table layout --> <!-- Table layout -->
<div class="relative"> <div class="relative">
<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-3 py-1.5 hover:bg-surface2 transition flex items-center gap-2.5 justify-between text-sm"
[attr.data-submenu]="'tableLayout'" [attr.data-submenu]="'tableLayout'"
(mouseenter)="onOpenSubmenu($event, 'tableLayout')" (mouseenter)="onOpenSubmenu($event, 'tableLayout')"
(click)="toggleSubmenu($event, 'tableLayout')" (click)="toggleSubmenu($event, 'tableLayout')"
> >
<div class="flex items-center gap-3"> <div class="flex items-center gap-2.5">
<span class="text-base">📐</span> <span class="text-base">📐</span>
<span>Table layout</span> <span>Table layout</span>
</div> </div>
@ -610,7 +611,7 @@ export interface MenuAction {
<!-- Copy table --> <!-- Copy table -->
<button <button
class="w-full text-left px-4 py-2 hover:bg-surface2 transition flex items-center gap-3" class="w-full text-left px-3 py-1.5 hover:bg-surface2 transition flex items-center gap-2.5 text-sm"
(click)="onAction('copyTable')" (click)="onAction('copyTable')"
> >
<span class="text-base">📋</span> <span class="text-base">📋</span>
@ -619,7 +620,7 @@ export interface MenuAction {
<!-- Filter --> <!-- Filter -->
<button <button
class="w-full text-left px-4 py-2 hover:bg-surface2 transition flex items-center gap-3" class="w-full text-left px-3 py-1.5 hover:bg-surface2 transition flex items-center gap-2.5 text-sm"
(click)="onAction('filterTable')" (click)="onAction('filterTable')"
> >
<span class="text-base">🔍</span> <span class="text-base">🔍</span>
@ -628,7 +629,7 @@ export interface MenuAction {
<!-- Import from CSV --> <!-- Import from CSV -->
<button <button
class="w-full text-left px-4 py-2 hover:bg-surface2 transition flex items-center gap-3" class="w-full text-left px-3 py-1.5 hover:bg-surface2 transition flex items-center gap-2.5 text-sm"
(click)="onAction('importCSV')" (click)="onAction('importCSV')"
> >
<span class="text-base">📥</span> <span class="text-base">📥</span>
@ -636,7 +637,7 @@ export interface MenuAction {
</button> </button>
<!-- Insert column (submenu inline avec 3 icônes) --> <!-- Insert column (submenu inline avec 3 icônes) -->
<div class="px-4 py-2 border-t border-border dark:border-gray-700"> <div class="px-3 py-1.5 border-t border-border dark:border-gray-700">
<div class="text-xs text-text-muted dark:text-neutral-500 mb-2">Insert column</div> <div class="text-xs text-text-muted dark:text-neutral-500 mb-2">Insert column</div>
<div class="flex gap-2"> <div class="flex gap-2">
<button <button
@ -675,7 +676,7 @@ export interface MenuAction {
<!-- Help --> <!-- Help -->
<button <button
class="w-full text-left px-4 py-2 hover:bg-surface2 dark:hover:bg-gray-700 transition flex items-center gap-3" class="w-full text-left px-3 py-1.5 hover:bg-surface2 transition flex items-center gap-2.5 text-sm"
(click)="onAction('tableHelp')" (click)="onAction('tableHelp')"
> >
<span class="text-base"></span> <span class="text-base"></span>
@ -686,7 +687,7 @@ export interface MenuAction {
<div class="h-px bg-border dark:bg-gray-700 my-1"></div> <div class="h-px bg-border dark:bg-gray-700 my-1"></div>
<button <button
class="w-full text-left px-4 py-2 hover:bg-surface2 transition flex items-center gap-3" class="w-full text-left px-3 py-1.5 hover:bg-surface2 transition flex items-center gap-2.5 text-sm"
(click)="onAction('duplicate')" (click)="onAction('duplicate')"
> >
<span class="text-base">📋</span> <span class="text-base">📋</span>
@ -694,7 +695,7 @@ export interface MenuAction {
</button> </button>
<button <button
class="w-full text-left px-4 py-2 hover:bg-surface2 transition flex items-center gap-3" class="w-full text-left px-3 py-1.5 hover:bg-surface2 transition flex items-center gap-2.5 text-sm"
(click)="onAction('copy')" (click)="onAction('copy')"
> >
<span class="text-base">📄</span> <span class="text-base">📄</span>
@ -702,7 +703,7 @@ export interface MenuAction {
</button> </button>
<button <button
class="w-full text-left px-4 py-2 hover:bg-surface2 transition flex items-center gap-3" class="w-full text-left px-3 py-1.5 hover:bg-surface2 transition flex items-center gap-2.5 text-sm"
(click)="onAction('lock')" (click)="onAction('lock')"
> >
<span class="text-base">🔒</span> <span class="text-base">🔒</span>
@ -710,7 +711,7 @@ export interface MenuAction {
</button> </button>
<button <button
class="w-full text-left px-4 py-2 hover:bg-surface2 transition flex items-center gap-3" class="w-full text-left px-3 py-1.5 hover:bg-surface2 transition flex items-center gap-2.5 text-sm"
(click)="onAction('copyLink')" (click)="onAction('copyLink')"
> >
<span class="text-base">🔗</span> <span class="text-base">🔗</span>
@ -720,7 +721,7 @@ export interface MenuAction {
<div class="h-px bg-border dark:bg-gray-700 my-1"></div> <div class="h-px bg-border dark:bg-gray-700 my-1"></div>
<button <button
class="w-full text-left px-4 py-2 hover:bg-red-500/10 text-red-500 transition flex items-center gap-3" class="w-full text-left px-3 py-1.5 hover:bg-red-500/10 text-red-500 transition flex items-center gap-2.5 text-sm"
(click)="onAction('delete')" (click)="onAction('delete')"
> >
<span class="text-base">🗑</span> <span class="text-base">🗑</span>
@ -731,82 +732,88 @@ export interface MenuAction {
<!-- Convert to submenu --> <!-- Convert to submenu -->
<div <div
*ngIf="showSubmenu === 'convert' && convertOptions.length" *ngIf="showSubmenu === 'convert' && convertOptions.length"
class="bg-surface1 border border-border rounded-lg shadow-xl min-w-[280px] py-2" class="submenu-pro bg-surface1 border border-border rounded-lg shadow-xl min-w-[260px] max-h-[420px] overflow-y-auto p-1.5"
[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()" (mousedown)="$event.stopPropagation()"
> >
@for (group of groupedConvertOptions | keyvalue; track group.key) {
<div class="mb-1.5 last:mb-0">
<div class="text-[10px] font-semibold tracking-wide text-text-muted uppercase px-3 pt-2 pb-1">{{ group.key }}</div>
@for (item of group.value; track item.label) {
<button <button
*ngFor="let item of convertOptions" class="w-full text-left px-3 py-1 rounded-md hover:bg-surface-hover transition flex items-center justify-between text-[13px] leading-tight"
class="w-full text-left px-4 py-2 hover:bg-surface2 dark:hover:bg-gray-700 transition flex items-center justify-between"
(mousedown)="onConvert(item.type, item.preset)" (mousedown)="onConvert(item.type, item.preset)"
(click)="$event.preventDefault()" (click)="$event.preventDefault()"
> >
<div class="flex items-center gap-3"> <div class="flex items-center gap-2.5">
<span class="text-base w-5 flex items-center justify-center"> <span class="text-sm w-5 h-5 flex items-center justify-center text-text-muted">{{ item.icon }}</span>
@if (item.type === 'list-item' && item.preset?.kind === 'check') { <span class="text-text-main truncate">{{ item.label }}</span>
<svg class="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="3" y="3" width="5" height="5" rx="1"/>
<path d="M10 6h10"/>
<rect x="3" y="10" width="5" height="5" rx="1"/>
<path d="M10 13h10"/>
<rect x="3" y="17" width="5" height="5" rx="1"/>
<path d="M10 20h10"/>
</svg>
} @else if (item.type === 'list-item' && item.preset?.kind === 'bullet') {
<svg class="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="5.5" cy="5.5" r="1.5"/>
<path d="M10 6h10"/>
<circle cx="5.5" cy="12.5" r="1.5"/>
<path d="M10 13h10"/>
<circle cx="5.5" cy="19.5" r="1.5"/>
<path d="M10 20h10"/>
</svg>
} @else if (item.type === 'list-item' && item.preset?.kind === 'numbered') {
<svg class="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<text x="4" y="7" font-size="5" text-anchor="middle" dominant-baseline="middle" fill="currentColor" stroke="none">1</text>
<path d="M10 7h10"/>
<text x="4" y="14" font-size="5" text-anchor="middle" dominant-baseline="middle" fill="currentColor" stroke="none">2</text>
<path d="M10 14h10"/>
<text x="4" y="21" font-size="5" text-anchor="middle" dominant-baseline="middle" fill="currentColor" stroke="none">3</text>
<path d="M10 21h10"/>
</svg>
} @else {
{{ item.icon }}
}
</span>
<span>{{ item.label }}</span>
</div> </div>
<span class="text-xs text-text-muted">{{ item.shortcut }}</span> <span class="shortcut-key">{{ item.shortcut }}</span>
</button> </button>
}
</div>
}
</div> </div>
<!-- Background submenu moved into the Background row above --> <!-- Background submenu moved into the Background row above -->
</div> </div>
`, `,
styles: [` styles: [`
:host { display: contents; } :host {
position: fixed;
inset: 0;
pointer-events: none;
z-index: 2147483646;
}
.ctx { .ctx {
pointer-events: auto; pointer-events: auto;
border-radius: 0.75rem; border-radius: 0.5rem;
box-shadow: 0 10px 30px rgba(0,0,0,.25); box-shadow: 0 16px 40px rgba(0,0,0,.32);
background: var(--card, #ffffff); background: var(--card, #0b1120);
border: 1px solid var(--border, #e5e7eb); border: 1px solid var(--border, rgba(148,163,184,0.5));
color: var(--text-main, var(--fg, #111827)); color: var(--text-main, var(--fg, #e5e7eb));
z-index: 2147483646;
max-height: calc(100vh - 16px); max-height: calc(100vh - 16px);
overflow-y: auto; overflow-y: auto;
overflow-x: hidden; /* submenus are fixed-positioned; avoid horizontal scrollbar */ overflow-x: hidden;
font-size: 0.875rem;
animation: fadeIn .12s ease-out; animation: fadeIn .12s ease-out;
} }
/* Stronger highlight on hover/focus for all buttons inside the menu (override utility classes) */
.ctx button:hover, .ctx button:hover,
.ctx button:focus, .ctx button:focus,
.ctx [data-submenu-panel] button:hover { .ctx [data-submenu-panel] button:hover {
background: var(--menu-hover, rgba(0,0,0,0.16)) !important; background: var(--menu-hover, rgba(0,0,0,0.16)) !important;
} }
.submenu-pro {
scrollbar-width: thin;
}
.submenu-pro::-webkit-scrollbar {
width: 6px;
}
.submenu-pro::-webkit-scrollbar-track {
background: transparent;
}
.submenu-pro::-webkit-scrollbar-thumb {
background: rgba(148,163,184,0.6);
border-radius: 999px;
}
.submenu-pro .shortcut-key {
background-color: var(--surface2, #0f172a);
color: var(--text-muted, #9ca3af);
padding: 2px 8px;
border-radius: 999px;
font-size: 0.7rem;
border: 1px solid var(--border, rgba(148,163,184,0.7));
white-space: nowrap;
}
.dark .submenu-pro .shortcut-key {
background-color: var(--surface2, #020617);
color: var(--text-muted, #e5e7eb);
border-color: var(--border, rgba(148,163,184,0.9));
}
.ctx button:focus { outline: none; } .ctx button:focus { outline: none; }
@keyframes fadeIn { from { opacity:0; transform: scale(.97);} to { opacity:1; transform: scale(1);} } @keyframes fadeIn { from { opacity:0; transform: scale(.97);} to { opacity:1; transform: scale(1);} }
`] `]
@ -821,14 +828,24 @@ export class BlockContextMenuComponent implements OnChanges {
private documentService = inject(DocumentService); private documentService = inject(DocumentService);
private elementRef = inject(ElementRef); private elementRef = inject(ElementRef);
readonly codeThemeService = inject(CodeThemeService); readonly codeThemeService = inject(CodeThemeService);
private blockMenuStylingService = inject(BlockMenuStylingService);
private clipboardData: Block | null = null; private clipboardData: Block | null = null;
@ViewChild('menu') menuRef?: ElementRef<HTMLElement>; @ViewChild('menu') menuRef?: ElementRef<HTMLElement>;
// viewport-safe coordinates // viewport-safe coordinates
left = 0; left = -9999;
top = 0; top = -9999;
private repositionRaf: number | null = null; opacity = 0;
constructor() {
try {
const el = this.elementRef.nativeElement as HTMLElement;
if (el && el.parentElement !== document.body) {
document.body.appendChild(el);
}
} catch {}
}
@HostListener('document:click', ['$event']) @HostListener('document:click', ['$event'])
onDocumentClick(event: MouseEvent): void { onDocumentClick(event: MouseEvent): void {
@ -864,8 +881,8 @@ export class BlockContextMenuComponent implements OnChanges {
} }
} }
@HostListener('window:resize') onResize() { if (this.visible) this.scheduleReposition(); } @HostListener('window:resize') onResize() { if (this.visible) this.reposition(); }
@HostListener('window:scroll') onScroll() { if (this.visible) this.scheduleReposition(); } @HostListener('window:scroll') onScroll() { if (this.visible) this.reposition(); }
// If hovering a non-submenu option within the main menu, close any open submenu // If hovering a non-submenu option within the main menu, close any open submenu
@HostListener('mouseover', ['$event']) @HostListener('mouseover', ['$event'])
@ -886,32 +903,25 @@ export class BlockContextMenuComponent implements OnChanges {
} }
ngOnChanges(changes: SimpleChanges): void { ngOnChanges(changes: SimpleChanges): void {
if (changes['visible']) { if (changes['visible'] && this.visible) {
if (this.visible) { this.open();
this.left = this.position.x; } else if (changes['visible'] && !this.visible) {
this.top = this.position.y; this.opacity = 0;
this.scheduleReposition(); this.left = -9999;
queueMicrotask(() => this.focusFirstItem()); this.top = -9999;
} }
}
if ((changes['position']) && this.visible) { if (changes['position'] && this.visible) {
this.left = this.position.x; this.open();
this.top = this.position.y;
this.scheduleReposition();
} }
} }
private scheduleReposition() {
if (this.repositionRaf != null) cancelAnimationFrame(this.repositionRaf);
const el = this.menuRef?.nativeElement; if (el) el.style.visibility = 'hidden';
this.repositionRaf = requestAnimationFrame(() => { this.repositionRaf = null; this.reposition(); });
}
private reposition() { private reposition() {
const el = this.menuRef?.nativeElement; if (!el) return; const el = this.menuRef?.nativeElement; if (!el) return;
const rect = el.getBoundingClientRect(); const rect = el.getBoundingClientRect();
const vw = window.innerWidth; const vh = window.innerHeight; const vw = window.innerWidth; const vh = window.innerHeight;
let left = this.left; let top = this.top; let left = this.position.x; let top = this.position.y;
// horizontal clamp // horizontal clamp
if (left + rect.width > vw - 8) left = Math.max(8, vw - rect.width - 8); if (left + rect.width > vw - 8) left = Math.max(8, vw - rect.width - 8);
if (left < 8) left = 8; if (left < 8) left = 8;
@ -920,8 +930,11 @@ export class BlockContextMenuComponent implements OnChanges {
top = Math.max(8, top - rect.height); top = Math.max(8, top - rect.height);
} }
if (top < 8) top = 8; if (top < 8) top = 8;
// if still too tall, rely on max-height + scroll
this.left = left; this.top = top; if (el) el.style.visibility = 'visible'; this.left = left;
this.top = top;
this.opacity = 1;
// also keep any open submenu in position relative to its anchor // also keep any open submenu in position relative to its anchor
if (this.showSubmenu && this._submenuAnchor) { if (this.showSubmenu && this._submenuAnchor) {
this.positionSubmenu(this.showSubmenu, this._submenuAnchor); this.positionSubmenu(this.showSubmenu, this._submenuAnchor);
@ -929,6 +942,18 @@ export class BlockContextMenuComponent implements OnChanges {
} }
// Keyboard navigation // Keyboard navigation
private open() {
this.left = this.position.x;
this.top = this.position.y;
this.opacity = 0; // Keep it hidden until repositioned
// Wait for the DOM to update with the new position
setTimeout(() => {
this.reposition();
queueMicrotask(() => this.focusFirstItem());
}, 0);
}
@HostListener('window:keydown', ['$event']) @HostListener('window:keydown', ['$event'])
onKey(e: KeyboardEvent) { onKey(e: KeyboardEvent) {
if (!this.visible) return; if (!this.visible) return;
@ -1000,29 +1025,46 @@ export class BlockContextMenuComponent implements OnChanges {
if (!anchor) return; if (!anchor) return;
const panel = document.querySelector(`[data-submenu-panel="${id}"]`) as HTMLElement | null; const panel = document.querySelector(`[data-submenu-panel="${id}"]`) as HTMLElement | null;
if (!panel) return; if (!panel) return;
const r = anchor.getBoundingClientRect(); const r = anchor.getBoundingClientRect();
const vw = window.innerWidth; const vh = window.innerHeight; const vw = window.innerWidth;
// ensure fixed positioning so it never affects the main menu scroll area const vh = window.innerHeight;
const gap = 4; // Gap between main menu and submenu
// Temporarily position off-screen to measure dimensions
panel.style.visibility = 'hidden';
panel.style.position = 'fixed'; panel.style.position = 'fixed';
panel.style.maxHeight = Math.max(100, vh - 16) + 'px'; panel.style.left = '-9999px';
// First try opening to the right (small gap to allow mouse travel) panel.style.top = '-9999px';
let left = r.right + 4; panel.style.maxHeight = `${vh - 16}px`;
// place top aligned with anchor top
const pw = panel.offsetWidth;
const ph = panel.offsetHeight;
let left = r.right + gap;
let top = r.top; let top = r.top;
// Measure panel size (after position temp offscreen)
panel.style.left = '-9999px'; panel.style.top = '-9999px'; // Adjust horizontal position
const pw = panel.offsetWidth || 260; const ph = panel.offsetHeight || 200;
// Auto-invert horizontally if overflowing
if (left + pw > vw - 8) { if (left + pw > vw - 8) {
left = Math.max(8, r.left - pw - 2); left = r.left - pw - gap;
} }
// Clamp vertical within viewport if (left < 8) {
if (top + ph > vh - 8) top = Math.max(8, vh - ph - 8); left = 8;
if (top < 8) top = 8; }
// Apply
this.submenuStyle[id] = { position: 'fixed', left: left + 'px', top: top + 'px' }; // Adjust vertical position
panel.style.left = left + 'px'; if (top + ph > vh - 8) {
panel.style.top = top + 'px'; top = vh - ph - 8;
}
if (top < 8) {
top = 8;
}
// Apply final position and make visible
this.submenuStyle[id] = { position: 'fixed', left: `${left}px`, top: `${top}px` };
panel.style.left = `${left}px`;
panel.style.top = `${top}px`;
panel.style.visibility = 'visible';
} }
private maybeCloseSubmenuOnFocusChange(focused: HTMLElement) { private maybeCloseSubmenuOnFocusChange(focused: HTMLElement) {
@ -1115,25 +1157,7 @@ export class BlockContextMenuComponent implements OnChanges {
} }
get 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: '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: 'toggle' as BlockType, preset: null, icon: '▶️', label: 'Toggle Block', shortcut: 'ctrl+alt+6' },
{ type: 'paragraph' as BlockType, preset: null, icon: '¶', label: 'Paragraph', shortcut: 'ctrl+alt+7' },
{ type: 'steps' as BlockType, preset: null, icon: '📝', label: 'Steps', shortcut: '' },
{ type: 'heading' as BlockType, preset: { level: 1 }, icon: 'H₁', label: 'Large Heading', shortcut: 'ctrl+alt+1' },
{ type: 'heading' as BlockType, preset: { level: 2 }, icon: 'H₂', label: 'Medium Heading', shortcut: 'ctrl+alt+2' },
{ type: 'heading' as BlockType, preset: { level: 3 }, icon: 'H₃', label: 'Small Heading', shortcut: 'ctrl+alt+3' },
{ type: 'code' as BlockType, preset: null, icon: '</>', label: 'Code', shortcut: 'ctrl+alt+c' },
{ type: 'quote' as BlockType, preset: null, icon: '❝', label: 'Quote', shortcut: 'ctrl+alt+y' },
{ type: 'hint' as BlockType, preset: null, icon: '', label: 'Hint', shortcut: 'ctrl+alt+u' },
{ type: 'button' as BlockType, preset: null, icon: '🔘', label: 'Button', shortcut: 'ctrl+alt+5' }
];
// Restrict for file/image per requirements
if (this.block?.type === 'file') { if (this.block?.type === 'file') {
// only when underlying file is an image kind
const props: any = this.block.props || {}; const props: any = this.block.props || {};
const meta = props.meta || {}; const meta = props.meta || {};
let kind = meta.kind as string | undefined; let kind = meta.kind as string | undefined;
@ -1143,14 +1167,18 @@ export class BlockContextMenuComponent implements OnChanges {
if (['png','jpg','jpeg','gif','webp','bmp','svg'].includes(ext)) kind = 'image'; if (['png','jpg','jpeg','gif','webp','bmp','svg'].includes(ext)) kind = 'image';
} }
if (kind === 'image') { if (kind === 'image') {
return [{ type: 'image' as BlockType, preset: null, icon: '🖼️', label: 'Image', shortcut: '' }]; return [{ type: 'image' as BlockType, preset: null, icon: '🖼️', label: 'Image', shortcut: '', category: 'MEDIA' }];
} }
return []; return [];
} }
if (this.block?.type === 'image') { if (this.block?.type === 'image') {
return [{ type: 'file' as BlockType, preset: null, icon: '📎', label: 'File', shortcut: '' }]; return [{ type: 'file' as BlockType, preset: null, icon: '📎', label: 'File', shortcut: '', category: 'MEDIA' }];
} }
return base; return this.blockMenuStylingService.getConvertOptions();
}
get groupedConvertOptions() {
return this.blockMenuStylingService.getGroupedConvertOptions();
} }
backgroundColors = [ backgroundColors = [

View File

@ -88,12 +88,12 @@ import { CollapsibleBlockComponent } from './blocks/collapsible-block.component'
@if (block.type !== 'columns') { @if (block.type !== 'columns') {
<button <button
type="button" type="button"
class="menu-handle opacity-0 group-hover:opacity-100 transition-opacity absolute -left-8 top-1/2 -translate-y-1/2 p-1.5 rounded hover:bg-surface2 dark:hover:bg-gray-700" class="menu-handle opacity-0 group-hover:opacity-100 transition-opacity absolute -left-5 top-1/2 -translate-y-1/2 w-5 h-5 flex items-center justify-center rounded-full bg-surface2/80 hover:bg-surface2 dark:bg-gray-700/80 dark:hover:bg-gray-700 shadow-sm"
title="Click to open menu" title="Click to open menu"
(click)="onMenuClick($event)" (click)="onMenuClick($event)"
(mousedown)="onDragStart($event)" (mousedown)="onDragStart($event)"
> >
<svg class="w-5 h-5 text-text-muted" fill="currentColor" viewBox="0 0 16 16"> <svg class="w-2 h-2 text-gray-300" fill="currentColor" viewBox="0 0 16 16">
<circle cx="3" cy="8" r="1.5"/> <circle cx="3" cy="8" r="1.5"/>
<circle cx="8" cy="8" r="1.5"/> <circle cx="8" cy="8" r="1.5"/>
<circle cx="13" cy="8" r="1.5"/> <circle cx="13" cy="8" r="1.5"/>
@ -190,7 +190,7 @@ import { CollapsibleBlockComponent } from './blocks/collapsible-block.component'
} }
} }
</div> </div>
<ng-container *ngIf="block.type !== 'table'"> <ng-container *ngIf="block.type !== 'table' && block.type !== 'columns'">
<!-- Filled white speech bubble with count (count in black) --> <!-- Filled white speech bubble with count (count in black) -->
<button *ngIf="totalComments() > 0" class="absolute top-1/2 -translate-y-1/2 right-2 w-8 h-8 flex items-center justify-center z-20" <button *ngIf="totalComments() > 0" class="absolute top-1/2 -translate-y-1/2 right-2 w-8 h-8 flex items-center justify-center z-20"
title="View comments" (click)="openComments()"> title="View comments" (click)="openComments()">

View File

@ -10,7 +10,7 @@ export interface InlineToolbarAction {
standalone: true, standalone: true,
imports: [CommonModule], imports: [CommonModule],
template: ` template: `
<div class="group/block relative flex items-center justify-between gap-2 bg-[var(--editor-bg)] rounded-2xl px-4 py-2 shadow-sm z-[60]"> <div class="group/block relative flex items-center justify-between gap-2 bg-[var(--block-bg,var(--editor-bg))] rounded-2xl px-4 py-1 shadow-sm z-[10]">
<!-- Drag handle (visible on hover) --> <!-- Drag handle (visible on hover) -->
@if (showDragHandle) { @if (showDragHandle) {
<div <div
@ -205,6 +205,7 @@ export interface InlineToolbarAction {
<button *ngIf="!actions || actions.includes('more')" <button *ngIf="!actions || actions.includes('more')"
class="p-1.5 rounded transition-colors text-gray-400 hover:text-gray-100 cursor-pointer" class="p-1.5 rounded transition-colors text-gray-400 hover:text-gray-100 cursor-pointer"
title="More items" title="More items"
data-inline-more="true"
(click)="onAction('more')" (click)="onAction('more')"
type="button" type="button"
> >

View File

@ -1,4 +1,4 @@
import { Component, Input, Output, EventEmitter, inject, ViewChild, signal, effect } from '@angular/core'; import { Component, Input, Output, EventEmitter, inject, ViewChild, signal, effect, ElementRef, HostListener, AfterViewInit } from '@angular/core';
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { Block, ColumnsProps, ColumnItem } from '../../../core/models/block.model'; import { Block, ColumnsProps, ColumnItem } from '../../../core/models/block.model';
import { DragDropService } from '../../../services/drag-drop.service'; import { DragDropService } from '../../../services/drag-drop.service';
@ -30,6 +30,7 @@ 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'; import { DragDropFilesDirective } from '../../../../blocks/file/directives/drag-drop-files.directive';
import { PaletteItem } from '../../../core/constants/palette-items';
@Component({ @Component({
selector: 'app-columns-block', selector: 'app-columns-block',
@ -95,7 +96,7 @@ import { DragDropFilesDirective } from '../../../../blocks/file/directives/drag-
<!-- Render block with background color support --> <!-- Render block with background color support -->
<div <div
class="relative px-1.5 py-0.5 pr-8 rounded transition-colors" class="relative px-1.5 py-1 pr-8 rounded transition-colors"
[style.background-color]="getBlockBgColor(block)" [style.background-color]="getBlockBgColor(block)"
[ngStyle]="getBlockStyles(block)" [ngStyle]="getBlockStyles(block)"
> >
@ -129,6 +130,7 @@ import { DragDropFilesDirective } from '../../../../blocks/file/directives/drag-
(metaChange)="onBlockMetaChange($event, block.id)" (metaChange)="onBlockMetaChange($event, block.id)"
(createBlock)="onBlockCreateBelow(block.id, colIndex, blockIndex)" (createBlock)="onBlockCreateBelow(block.id, colIndex, blockIndex)"
(deleteBlock)="onBlockDelete(block.id)" (deleteBlock)="onBlockDelete(block.id)"
(convertRequested)="onConvertRequested($event, block.id)"
/> />
} }
@case ('list-item') { @case ('list-item') {
@ -205,6 +207,15 @@ import { DragDropFilesDirective } from '../../../../blocks/file/directives/drag-
} }
</div> </div>
} }
@if (props.columns.length > 1) {
@for (i of resizerIndexes; track i) {
<div
class="col-resizer absolute top-0 bottom-0 w-2 cursor-col-resize"
[style.left.px]="resizerPositions()[i]"
(mousedown)="onResizerDown(i, $event)"
></div>
}
}
</div> </div>
<!-- Comments Panel --> <!-- Comments Panel -->
@ -237,9 +248,17 @@ import { DragDropFilesDirective } from '../../../../blocks/file/directives/drag-
[contenteditable]:focus { [contenteditable]:focus {
outline: none; outline: none;
} }
.col-resizer {
/* visually subtle hit zone */
transform: translateX(-4px);
}
.col-resizer:hover {
background: rgba(56, 189, 248, 0.15);
}
`] `]
}) })
export class ColumnsBlockComponent { export class ColumnsBlockComponent implements AfterViewInit {
private readonly dragDrop = inject(DragDropService); private readonly dragDrop = inject(DragDropService);
private readonly commentService = inject(CommentService); private readonly commentService = inject(CommentService);
private readonly documentService = inject(DocumentService); private readonly documentService = inject(DocumentService);
@ -248,6 +267,7 @@ export class ColumnsBlockComponent {
@Input({ required: true }) block!: Block<ColumnsProps>; @Input({ required: true }) block!: Block<ColumnsProps>;
@Output() update = new EventEmitter<ColumnsProps>(); @Output() update = new EventEmitter<ColumnsProps>();
@ViewChild('commentsPanel') commentsPanel?: CommentsPanelComponent; @ViewChild('commentsPanel') commentsPanel?: CommentsPanelComponent;
@ViewChild('columnsContainer', { static: true }) columnsContainerRef!: ElementRef<HTMLElement>;
// Menu state // Menu state
selectedBlock = signal<Block | null>(null); selectedBlock = signal<Block | null>(null);
@ -258,6 +278,11 @@ export class ColumnsBlockComponent {
private draggedBlock: { block: Block; columnIndex: number; blockIndex: number } | null = null; private draggedBlock: { block: Block; columnIndex: number; blockIndex: number } | null = null;
private dropIndicator = signal<{ columnIndex: number; blockIndex: number } | null>(null); private dropIndicator = signal<{ columnIndex: number; blockIndex: number } | null>(null);
// Resize state
private readonly MIN_COL_WIDTH = 10; // percent
private resizeState: { active: boolean; index: number; startX: number; containerWidth: number; leftStart: number; rightStart: number } | null = null;
resizerPositions = signal<number[]>([]);
get props(): ColumnsProps { get props(): ColumnsProps {
return this.block.props; return this.block.props;
} }
@ -266,6 +291,29 @@ export class ColumnsBlockComponent {
return this.commentService.getCommentCount(blockId); return this.commentService.getCommentCount(blockId);
} }
onConvertRequested(item: PaletteItem, blockId: string): void {
// Map palette selection to block type and optional preset
let newType = item.type as any;
let preset: any = undefined;
// Headings levels
if (item.id === 'heading-1') preset = { level: 1 };
else if (item.id === 'heading-2') preset = { level: 2 };
else if (item.id === 'heading-3') preset = { level: 3 };
// Lists -> list-item presets
if (item.id === 'checkbox-list') { newType = 'list-item'; preset = { kind: 'check', checked: false }; }
else if (item.id === 'numbered-list') { newType = 'list-item'; preset = { kind: 'numbered', number: 1 }; }
else if (item.id === 'bullet-list') { newType = 'list-item'; preset = { kind: 'bullet' }; }
// Collapsible variants
if (item.id === 'collapsible-large') { newType = 'collapsible'; preset = { level: 1 }; }
else if (item.id === 'collapsible-medium') { newType = 'collapsible'; preset = { level: 2 }; }
else if (item.id === 'collapsible-small') { newType = 'collapsible'; preset = { level: 3 }; }
// Apply conversion within columns
this.convertBlockInColumns(blockId, newType, preset);
}
openComments(blockId: string): void { openComments(blockId: string): void {
this.commentsPanel?.open(blockId); this.commentsPanel?.open(blockId);
} }
@ -600,6 +648,9 @@ export class ColumnsBlockComponent {
} }
getBlockBgColor(block: Block): string | undefined { getBlockBgColor(block: Block): string | undefined {
if (block.type === 'paragraph') {
return undefined;
}
const bgColor = (block.meta as any)?.bgColor; const bgColor = (block.meta as any)?.bgColor;
return bgColor && bgColor !== 'transparent' ? bgColor : undefined; return bgColor && bgColor !== 'transparent' ? bgColor : undefined;
} }
@ -625,6 +676,15 @@ export class ColumnsBlockComponent {
return Math.random().toString(36).substring(2, 11); return Math.random().toString(36).substring(2, 11);
} }
ngAfterViewInit(): void {
setTimeout(() => this.computeResizerPositions(), 0);
}
@HostListener('window:resize')
onWindowResize(): void {
this.computeResizerPositions();
}
onDragStart(block: Block, columnIndex: number, blockIndex: number, event: MouseEvent): void { onDragStart(block: Block, columnIndex: number, blockIndex: number, event: MouseEvent): void {
event.stopPropagation(); event.stopPropagation();
@ -796,4 +856,77 @@ export class ColumnsBlockComponent {
// Emit the updated columns // Emit the updated columns
this.update.emit({ columns: updatedColumns }); this.update.emit({ columns: updatedColumns });
} }
// Resizer helpers
get resizerIndexes(): number[] {
const n = (this.props.columns?.length || 0) - 1;
if (n <= 0) return [];
return Array.from({ length: n }, (_, i) => i);
}
private computeResizerPositions(): void {
try {
const container = this.columnsContainerRef?.nativeElement;
if (!container) return;
const cols = Array.from(container.querySelectorAll<HTMLElement>('[data-column-index]'));
const crect = container.getBoundingClientRect();
const positions: number[] = [];
for (let i = 0; i < cols.length - 1; i++) {
const r = cols[i].getBoundingClientRect();
positions.push(r.right - crect.left);
}
this.resizerPositions.set(positions);
} catch {}
}
onResizerDown(i: number, event: MouseEvent): void {
event.preventDefault();
event.stopPropagation();
const container = this.columnsContainerRef?.nativeElement;
if (!container) return;
const crect = container.getBoundingClientRect();
const cols = this.props.columns || [];
const left = cols[i];
const right = cols[i + 1];
const leftStart = Number(left?.width ?? (100 / cols.length));
const rightStart = Number(right?.width ?? (100 / cols.length));
this.resizeState = {
active: true,
index: i,
startX: event.clientX,
containerWidth: Math.max(1, crect.width),
leftStart,
rightStart
};
const onMove = (e: MouseEvent) => {
if (!this.resizeState) return;
const dx = e.clientX - this.resizeState.startX;
const dxPct = (dx / this.resizeState.containerWidth) * 100;
const sum = this.resizeState.leftStart + this.resizeState.rightStart;
let newLeft = this.resizeState.leftStart + dxPct;
// Clamp with min constraints
const min = this.MIN_COL_WIDTH;
newLeft = Math.max(min, Math.min(sum - min, newLeft));
const newRight = sum - newLeft;
const updated = (this.props.columns || []).map((col, idx) => {
if (idx === i) return { ...col, width: newLeft } as ColumnItem;
if (idx === i + 1) return { ...col, width: newRight } as ColumnItem;
return col;
});
this.update.emit({ columns: updated });
// Recompute positions on next tick to reflect DOM changes
setTimeout(() => this.computeResizerPositions(), 0);
};
const onUp = () => {
document.removeEventListener('mousemove', onMove);
document.removeEventListener('mouseup', onUp);
this.resizeState = null;
this.computeResizerPositions();
};
document.addEventListener('mousemove', onMove);
document.addEventListener('mouseup', onUp, { once: true });
}
} }

View File

@ -121,37 +121,65 @@ export class HeadingBlockComponent implements AfterViewInit {
return; return;
} }
// Up/Down: navigate to previous/next block when at start/end // Up/Down: navigate to previous/next block while preserving caret column
if (event.key === 'ArrowUp' || event.key === 'ArrowDown') { if (event.key === 'ArrowUp' || event.key === 'ArrowDown') {
const el = (event.target as HTMLElement); const el = event.target as HTMLElement;
const text = el.textContent || ''; const text = el.textContent || '';
const sel = window.getSelection(); const sel = window.getSelection();
if (!sel) return; if (!sel) return;
const atStart = sel.anchorOffset === 0; const atStart = sel.anchorOffset === 0;
const atEnd = sel.anchorOffset === text.length; const atEnd = sel.anchorOffset === text.length;
const singleLine = !text.includes('\n');
if (event.key === 'ArrowUp' && atStart) { (window as any).__obsiviewerCaretColumn = sel.anchorOffset;
if (event.key === 'ArrowUp') {
if (singleLine || atStart) {
event.preventDefault(); event.preventDefault();
this.focusSibling(-1); this.focusSibling(-1);
} }
if (event.key === 'ArrowDown' && atEnd) { }
if (event.key === 'ArrowDown') {
if (singleLine || atEnd) {
event.preventDefault(); event.preventDefault();
this.focusSibling(1); this.focusSibling(1);
} }
} }
} }
}
private focusSibling(delta: number): void { private focusSibling(delta: number): void {
// Access DocumentService via window DI not available; rely on document structure
setTimeout(() => { setTimeout(() => {
const host = (this.editable?.nativeElement?.closest('[data-block-id]')) as HTMLElement | null; const host = this.editable?.nativeElement?.closest('[data-block-id]') as HTMLElement | null;
if (!host) return; if (!host) return;
const blocks = Array.from(document.querySelectorAll('[data-block-id]')) as HTMLElement[]; const blocksEls = Array.from(document.querySelectorAll('[data-block-id]')) as HTMLElement[];
const idx = blocks.findIndex(b => b === host); const idx = blocksEls.findIndex(b => b === host);
const next = blocks[idx + delta]; if (idx === -1) return;
const target = next?.querySelector('[contenteditable]') as HTMLElement | null;
target?.focus(); let i = idx + delta;
while (i >= 0 && i < blocksEls.length) {
const candidate = blocksEls[i];
const nextEditable = candidate.querySelector('input[type="text"], textarea, [contenteditable]') as HTMLElement | null;
if (nextEditable) {
nextEditable.focus();
const sel = window.getSelection();
if (!sel) return;
const range = document.createRange();
const text = nextEditable.textContent || '';
const desired = typeof (window as any).__obsiviewerCaretColumn === 'number'
? (window as any).__obsiviewerCaretColumn
: text.length;
const offset = Math.max(0, Math.min(text.length, desired));
range.setStart(nextEditable.firstChild || nextEditable, offset);
range.collapse(true);
sel.removeAllRanges();
sel.addRange(range);
return;
}
i += delta;
}
}, 0); }, 0);
} }
} }

View File

@ -19,12 +19,19 @@ import { SelectionService } from '../../../services/selection.service';
@if (kind() === 'bullet') { @if (kind() === 'bullet') {
<div class="rounded-full bg-slate-200" style="width: 8px; height: 8px;"></div> <div class="rounded-full bg-slate-200" style="width: 8px; height: 8px;"></div>
} @else if (kind() === 'check') { } @else if (kind() === 'check') {
<input type="checkbox" <button type="button"
class="cursor-pointer" class="cursor-pointer flex items-center justify-center focus:outline-none"
style="width: 20px; height: 20px; border: 2px solid rgba(148, 163, 184, 0.8); border-radius: 2px; background: transparent;" (click)="onToggleCheck(it.id, $event)"
[checked]="it.checked || false" (keydown)="onCheckboxKeyDown(i, $event)"
(change)="onCheckChange($event, it.id)" >
(click)="$event.stopPropagation()" /> <div class="flex items-center justify-center bg-transparent" style="width: 20px; height: 20px; border: 2px solid rgba(148, 163, 184, 0.8); border-radius: 2px;">
@if (it.checked) {
<svg viewBox="0 0 24 24" class="w-4 h-4 text-slate-900">
<path fill="currentColor" d="M9.5 17.25L4.75 12.5L6.16 11.09L9.5 14.43L17.84 6.09L19.25 7.5L9.5 17.25Z" />
</svg>
}
</div>
</button>
} @else { } @else {
<span class="text-slate-200" style="font-size: 16px; font-weight: 500;">{{ i + 1 }}.</span> <span class="text-slate-200" style="font-size: 16px; font-weight: 500;">{{ i + 1 }}.</span>
} }
@ -33,8 +40,10 @@ import { SelectionService } from '../../../services/selection.service';
<!-- Input pill - inherits block color or uses transparent background --> <!-- Input pill - inherits block color or uses transparent background -->
<input #inp type="text" <input #inp type="text"
class="flex-1 rounded-xl px-5 py-2.5 text-xl leading-7 focus:outline-none cursor-text border-none" class="flex-1 rounded-xl px-5 py-2.5 text-xl leading-7 focus:outline-none cursor-text border-none"
[class.text-slate-900]="hasBlockColor()" [class.text-slate-900]="hasBlockColor() && !(kind() === 'check' && it.checked)"
[class.text-slate-100]="!hasBlockColor()" [class.text-slate-100]="!hasBlockColor() && !(kind() === 'check' && it.checked)"
[class.text-slate-500]="kind() === 'check' && it.checked"
[class.opacity-60]="kind() === 'check' && it.checked"
[class.placeholder-slate-500]="hasBlockColor()" [class.placeholder-slate-500]="hasBlockColor()"
[class.placeholder-slate-200/60]="!hasBlockColor()" [class.placeholder-slate-200/60]="!hasBlockColor()"
[style.background-color]="getInputBackground()" [style.background-color]="getInputBackground()"
@ -153,9 +162,11 @@ export class ListBlockComponent implements OnInit, AfterViewInit {
this.emit(arr); this.emit(arr);
} }
onCheckChange(ev: Event, itemId: string): void { onToggleCheck(itemId: string, ev: MouseEvent): void {
const checked = (ev.target as HTMLInputElement).checked; ev.preventDefault();
const arr = this.items().map(item => item.id === itemId ? { ...item, checked } : item); ev.stopPropagation();
const current = this.items();
const arr = current.map(item => item.id === itemId ? { ...item, checked: !item.checked } : item);
this.items.set(arr); this.items.set(arr);
this.emit(arr); this.emit(arr);
} }
@ -178,6 +189,28 @@ export class ListBlockComponent implements OnInit, AfterViewInit {
return; return;
} }
// ArrowUp/ArrowDown: navigate between list items or to sibling blocks
if (ev.key === 'ArrowUp' || ev.key === 'ArrowDown') {
(window as any).__obsiviewerCaretColumn = input.selectionStart ?? 0;
ev.preventDefault();
const items = this.items();
const lastIndex = items.length - 1;
if (ev.key === 'ArrowUp') {
if (i > 0) {
this.focusItemWithColumn(i - 1);
} else {
this.focusSiblingBlock(-1, i);
}
} else {
if (i < lastIndex) {
this.focusItemWithColumn(i + 1);
} else {
this.focusSiblingBlock(1, i);
}
}
return;
}
// Slash in prompt opens palette // Slash in prompt opens palette
if (ev.key === '/' && this.promptIndex() !== null) { if (ev.key === '/' && this.promptIndex() !== null) {
ev.preventDefault(); ev.preventDefault();
@ -194,6 +227,30 @@ export class ListBlockComponent implements OnInit, AfterViewInit {
} }
} }
onCheckboxKeyDown(i: number, ev: KeyboardEvent): void {
// Up/Down on the checkbox itself should behave like on the text input
if (ev.key === 'ArrowUp' || ev.key === 'ArrowDown') {
(window as any).__obsiviewerCaretColumn = 0;
ev.preventDefault();
const items = this.items();
const lastIndex = items.length - 1;
if (ev.key === 'ArrowUp') {
if (i > 0) {
this.focusItemWithColumn(i - 1);
} else {
this.focusSiblingBlock(-1, i);
}
} else {
if (i < lastIndex) {
this.focusItemWithColumn(i + 1);
} else {
this.focusSiblingBlock(1, i);
}
}
return;
}
}
insertAfter(i: number) { insertAfter(i: number) {
const arr = [...this.items()]; const arr = [...this.items()];
arr.splice(i + 1, 0, { id: generateItemId(), text: '', checked: this.kind() === 'check' ? false : undefined }); arr.splice(i + 1, 0, { id: generateItemId(), text: '', checked: this.kind() === 'check' ? false : undefined });
@ -219,12 +276,64 @@ export class ListBlockComponent implements OnInit, AfterViewInit {
focus(i: number) { focus(i: number) {
const el = this.inputs?.get(i)?.nativeElement; const el = this.inputs?.get(i)?.nativeElement;
el?.focus(); el?.focus();
const len = el?.value.length ?? 0; }
el?.setSelectionRange(len, len);
private focusItemWithColumn(i: number): void {
const el = this.inputs?.get(i)?.nativeElement;
if (!el) return;
el.focus();
const len = el.value.length;
const stored = (window as any).__obsiviewerCaretColumn;
const pos = typeof stored === 'number' ? Math.max(0, Math.min(len, stored)) : len;
el.setSelectionRange(pos, pos);
}
private focusSiblingBlock(delta: number, fromIndex: number): void {
const source = this.inputs?.get(fromIndex)?.nativeElement || this.inputs?.first?.nativeElement;
if (!source) return;
const host = source.closest('[data-block-id]') as HTMLElement | null;
if (!host) return;
const blocksEls = Array.from(document.querySelectorAll('[data-block-id]')) as HTMLElement[];
const idx = blocksEls.findIndex(b => b === host);
if (idx === -1) return;
let j = idx + delta;
while (j >= 0 && j < blocksEls.length) {
const candidate = blocksEls[j];
// Prefer text inputs/contenteditable over checkboxes when entering another block
const nextEditable = candidate.querySelector('input[type="text"], textarea, [contenteditable]') as HTMLElement | null;
if (nextEditable) {
const id = candidate.getAttribute('data-block-id');
if (id) {
try { this.selection.setActive(id); } catch {}
}
nextEditable.focus();
const stored = (window as any).__obsiviewerCaretColumn;
if (nextEditable instanceof HTMLInputElement || nextEditable instanceof HTMLTextAreaElement) {
const len = nextEditable.value.length;
const pos = typeof stored === 'number' ? Math.max(0, Math.min(len, stored)) : len;
nextEditable.setSelectionRange(pos, pos);
} else {
const sel = window.getSelection();
if (!sel) return;
const range = document.createRange();
const text = nextEditable.textContent || '';
const desired = typeof stored === 'number' ? stored : text.length;
const offset = Math.max(0, Math.min(text.length, desired));
range.setStart(nextEditable.firstChild || nextEditable, offset);
range.collapse(true);
sel.removeAllRanges();
sel.addRange(range);
}
return;
}
j += delta;
}
} }
onItemClick(i: number): void { onItemClick(i: number): void {
this.focus(i); const el = this.inputs?.get(i)?.nativeElement;
el?.focus();
} }
@HostListener('document:keydown', ['$event']) @HostListener('document:keydown', ['$event'])

View File

@ -10,18 +10,30 @@ import { DocumentService } from '../../../services/document.service';
standalone: true, standalone: true,
imports: [CommonModule, FormsModule], imports: [CommonModule, FormsModule],
template: ` template: `
<div class="flex items-center gap-3 cursor-text w-full" (click)="focusInput()" [style.padding-left.px]="getIndentPadding()"> <div class="flex items-center gap-3 cursor-text w-full" (click)="onContainerClick($event)" [style.padding-left.px]="getIndentPadding()">
<!-- Marker (bullet, checkbox, or number) --> <!-- Marker (bullet, checkbox, or number) -->
<div class="flex-shrink-0 flex items-center justify-center" style="width: 24px; min-width: 24px;"> <div class="flex-shrink-0 flex items-center justify-center" style="width: 24px; min-width: 24px;">
@if (props.kind === 'bullet') { @if (props.kind === 'bullet') {
<span class="text-slate-200" style="font-size: 18px; line-height: 1;">{{ getBulletSymbol() }}</span> <span class="text-slate-200" style="font-size: 18px; line-height: 1;">{{ getBulletSymbol() }}</span>
} @else if (props.kind === 'check') { } @else if (props.kind === 'check') {
<input type="checkbox" <button type="button"
class="cursor-pointer" class="cursor-pointer flex items-center justify-center focus:outline-none"
style="width: 20px; height: 20px; border: 2px solid rgba(148, 163, 184, 0.8); border-radius: 2px; background: transparent;" (click)="onCheckboxToggle($event)"
[checked]="props.checked || false" (keydown)="onCheckboxKeyDown($event)"
(change)="onCheckChange($event)" [attr.aria-pressed]="props.checked ? 'true' : 'false'"
(click)="$event.stopPropagation()" /> >
<div
class="flex items-center justify-center"
[class.bg-slate-100]="props.checked"
[class.text-slate-900]="props.checked"
style="width: 20px; height: 20px; border: 2px solid rgba(148, 163, 184, 0.8); border-radius: 2px;">
@if (props.checked) {
<svg viewBox="0 0 24 24" class="w-4 h-4">
<path fill="currentColor" d="M9.5 17.25L4.75 12.5L6.16 11.09L9.5 14.43L17.84 6.09L19.25 7.5L9.5 17.25Z" />
</svg>
}
</div>
</button>
} @else { } @else {
<span class="text-slate-200" style="font-size: 16px; font-weight: 500;">{{ props.number || 1 }}.</span> <span class="text-slate-200" style="font-size: 16px; font-weight: 500;">{{ props.number || 1 }}.</span>
} }
@ -30,8 +42,10 @@ import { DocumentService } from '../../../services/document.service';
<!-- Input text - inherits block color or uses transparent background --> <!-- Input text - inherits block color or uses transparent background -->
<input #inp type="text" <input #inp type="text"
class="flex-1 rounded-xl px-5 py-2.5 text-xl leading-7 cursor-text border-none shadow-none" class="flex-1 rounded-xl px-5 py-2.5 text-xl leading-7 cursor-text border-none shadow-none"
[class.text-slate-900]="hasBlockColor()" [class.text-slate-900]="hasBlockColor() && !(props.kind === 'check' && props.checked)"
[class.text-slate-100]="!hasBlockColor()" [class.text-slate-100]="!hasBlockColor() && !(props.kind === 'check' && props.checked)"
[class.text-slate-500]="props.kind === 'check' && props.checked"
[class.opacity-60]="props.kind === 'check' && props.checked"
[class.placeholder-slate-500]="hasBlockColor()" [class.placeholder-slate-500]="hasBlockColor()"
[class.placeholder-slate-200/60]="!hasBlockColor()" [class.placeholder-slate-200/60]="!hasBlockColor()"
[class.text-left]="getAlignment() === 'left'" [class.text-left]="getAlignment() === 'left'"
@ -123,11 +137,6 @@ export class ListItemBlockComponent implements OnInit, AfterViewInit {
this.update.emit({ ...this.props, text: v }); this.update.emit({ ...this.props, text: v });
} }
onCheckChange(ev: Event): void {
const checked = (ev.target as HTMLInputElement).checked;
this.update.emit({ ...this.props, checked });
}
onKeyDown(ev: KeyboardEvent): void { onKeyDown(ev: KeyboardEvent): void {
const input = ev.target as HTMLInputElement; const input = ev.target as HTMLInputElement;
@ -185,6 +194,33 @@ export class ListItemBlockComponent implements OnInit, AfterViewInit {
this.documentService.deleteBlock(this.block.id); this.documentService.deleteBlock(this.block.id);
return; return;
} }
// ArrowUp/ArrowDown: navigate between blocks while preserving caret column
if (ev.key === 'ArrowUp' || ev.key === 'ArrowDown') {
(window as any).__obsiviewerCaretColumn = input.selectionStart ?? 0;
ev.preventDefault();
this.focusSibling(ev.key === 'ArrowUp' ? -1 : 1);
return;
}
}
onCheckboxToggle(ev: Event): void {
ev.preventDefault();
ev.stopPropagation();
this.update.emit({ ...this.props, checked: !this.props.checked });
}
onCheckboxKeyDown(ev: KeyboardEvent): void {
if (ev.key === 'ArrowUp' || ev.key === 'ArrowDown') {
(window as any).__obsiviewerCaretColumn = 0;
ev.preventDefault();
this.focusSibling(ev.key === 'ArrowUp' ? -1 : 1);
return;
}
if (ev.key === ' ' || ev.key === 'Enter') {
this.onCheckboxToggle(ev);
}
} }
focusInput(): void { focusInput(): void {
@ -193,4 +229,51 @@ export class ListItemBlockComponent implements OnInit, AfterViewInit {
const len = el?.value.length ?? 0; const len = el?.value.length ?? 0;
el?.setSelectionRange(len, len); el?.setSelectionRange(len, len);
} }
onContainerClick(event: MouseEvent): void {
// If the user clicks directly into the input, let the browser place the caret
const target = event.target as HTMLElement;
if (target.tagName.toLowerCase() === 'input') {
return;
}
// Clicking elsewhere in the row focuses the input at end
this.focusInput();
}
private focusSibling(delta: number): void {
const host = this.input?.nativeElement.closest('[data-block-id]') as HTMLElement | null;
if (!host) return;
const blocksEls = Array.from(document.querySelectorAll('[data-block-id]')) as HTMLElement[];
const idx = blocksEls.findIndex(b => b === host);
if (idx === -1) return;
let i = idx + delta;
while (i >= 0 && i < blocksEls.length) {
const candidate = blocksEls[i];
const nextEditable = candidate.querySelector('input[type="text"], textarea, [contenteditable]') as HTMLElement | null;
if (nextEditable) {
nextEditable.focus();
const stored = (window as any).__obsiviewerCaretColumn;
if (nextEditable instanceof HTMLInputElement || nextEditable instanceof HTMLTextAreaElement) {
const len = nextEditable.value.length;
const desired = typeof stored === 'number' ? stored : len;
const pos = Math.max(0, Math.min(len, desired));
nextEditable.setSelectionRange(pos, pos);
} else {
const sel = window.getSelection();
if (!sel) return;
const range = document.createRange();
const text = nextEditable.textContent || '';
const desired = typeof stored === 'number' ? stored : text.length;
const offset = Math.max(0, Math.min(text.length, desired));
range.setStart(nextEditable.firstChild || nextEditable, offset);
range.collapse(true);
sel.removeAllRanges();
sel.addRange(range);
}
return;
}
i += delta;
}
}
} }

View File

@ -16,6 +16,7 @@ import { PaletteCategory, PaletteItem, getPaletteItemsByCategory, PALETTE_ITEMS
template: ` template: `
<div <div
class="relative" class="relative"
[style.--block-bg]="getBlockBgColor()"
(click)="onContainerClick($event)" (click)="onContainerClick($event)"
> >
<app-block-inline-toolbar <app-block-inline-toolbar
@ -30,14 +31,14 @@ import { PaletteCategory, PaletteItem, getPaletteItemsByCategory, PALETTE_ITEMS
<div <div
#editable #editable
contenteditable="true" contenteditable="true"
class="m-0 inline-block bg-transparent text-sm text-neutral-100 dark:text-neutral-100 focus:outline-none min-h-[1.25rem]" class="m-0 inline-block bg-transparent text-sm text-neutral-100 dark:text-neutral-100 focus:outline-none min-h-[1rem]"
(input)="onInput($event)" (input)="onInput($event)"
(keydown)="onKeyDown($event)" (keydown)="onKeyDown($event)"
(focus)="isFocused.set(true)" (focus)="isFocused.set(true)"
(blur)="onBlur()" (blur)="onBlur()"
[attr.data-placeholder]="inColumn ? columnPlaceholder : placeholder" [attr.data-placeholder]="inColumn ? columnPlaceholder : placeholder"
></div> ></div>
@if (inColumn) { @if (inColumn && isEmpty()) {
<button <button
type="button" type="button"
class="inline-flex items-center justify-center w-6 h-6 rounded-full border border-gray-500 text-gray-200 text-base leading-none hover:bg-gray-600 hover:border-gray-400 transition-colors select-none" class="inline-flex items-center justify-center w-6 h-6 rounded-full border border-gray-500 text-gray-200 text-base leading-none hover:bg-gray-600 hover:border-gray-400 transition-colors select-none"
@ -127,7 +128,7 @@ import { PaletteCategory, PaletteItem, getPaletteItemsByCategory, PALETTE_ITEMS
} }
[contenteditable] { [contenteditable] {
line-height: 1.25; line-height: 1.15;
} }
[contenteditable]:focus { [contenteditable]:focus {
@ -144,6 +145,8 @@ export class ParagraphBlockComponent implements AfterViewInit {
@Output() metaChange = new EventEmitter<any>(); @Output() metaChange = new EventEmitter<any>();
@Output() createBlock = new EventEmitter<void>(); @Output() createBlock = new EventEmitter<void>();
@Output() deleteBlock = new EventEmitter<void>(); @Output() deleteBlock = new EventEmitter<void>();
// In columns mode, request parent to convert this block based on palette selection
@Output() convertRequested = new EventEmitter<PaletteItem>();
private documentService = inject(DocumentService); private documentService = inject(DocumentService);
private selectionService = inject(SelectionService); private selectionService = inject(SelectionService);
@ -161,6 +164,12 @@ export class ParagraphBlockComponent implements AfterViewInit {
menuLeft = signal(0); menuLeft = signal(0);
categories: PaletteCategory[] = ['BASIC','ADVANCED','MEDIA','INTEGRATIONS','VIEW','TEMPLATES','HELPFUL LINKS']; categories: PaletteCategory[] = ['BASIC','ADVANCED','MEDIA','INTEGRATIONS','VIEW','TEMPLATES','HELPFUL LINKS'];
getBlockBgColor(): string | undefined {
const meta: any = this.block?.meta || {};
const bgColor = meta.bgColor;
return bgColor && bgColor !== 'transparent' ? bgColor : undefined;
}
onInlineAction(type: any): void { onInlineAction(type: any): void {
if (type === 'more' || type === 'menu') { if (type === 'more' || type === 'menu') {
this.openMenu(); this.openMenu();
@ -189,6 +198,14 @@ export class ParagraphBlockComponent implements AfterViewInit {
selectItem(item: PaletteItem): void { selectItem(item: PaletteItem): void {
try { this.selectionService.setActive(this.block.id); } catch {} try { this.selectionService.setActive(this.block.id); } catch {}
if (this.inColumn) {
// Delegate conversion to ColumnsBlockComponent
this.isEmpty.set(false);
this.convertRequested.emit(item);
this.moreOpen.set(false);
setTimeout(() => this.editable?.nativeElement?.focus(), 0);
return;
}
this.paletteService.applySelection(item); this.paletteService.applySelection(item);
this.moreOpen.set(false); this.moreOpen.set(false);
setTimeout(() => this.editable?.nativeElement?.focus(), 0); setTimeout(() => this.editable?.nativeElement?.focus(), 0);
@ -216,7 +233,7 @@ export class ParagraphBlockComponent implements AfterViewInit {
// Do not trigger container click / focus moves // Do not trigger container click / focus moves
event.stopPropagation(); event.stopPropagation();
event.preventDefault(); event.preventDefault();
this.openMenu(); this.openMenu(event);
} }
onKeyDown(event: KeyboardEvent): void { onKeyDown(event: KeyboardEvent): void {
@ -267,54 +284,76 @@ export class ParagraphBlockComponent implements AfterViewInit {
this.createBlock.emit(); this.createBlock.emit();
return; return;
} }
// Handle SHIFT+ENTER: Allow line break in contenteditable
if (event.key === 'Enter' && event.shiftKey) {
// Default behavior - line break within block
return;
} }
// Handle BACKSPACE on empty block: open dropdown instead of delete private openMenu(event?: MouseEvent): void {
if (event.key === 'Backspace') { this.moreOpen.set(true);
const target = event.target as HTMLElement; try { this.selectionService.setActive(this.block.id); } catch {}
const selection = window.getSelection(); // Compute viewport-safe position near the trigger icon (if provided),
if (selection && selection.anchorOffset === 0 && (!target.textContent || target.textContent.length === 0)) { // otherwise fall back to the editable paragraph like the old behavior.
event.preventDefault(); setTimeout(() => {
this.openMenu(); const vw = window.innerWidth;
return; const vh = window.innerHeight;
const panel = this.menuPanel?.nativeElement;
// Determine anchor rectangle: trigger element first, then inline toolbar, then editable block.
let anchorRect: DOMRect | null = null;
const target = (event?.currentTarget || event?.target) as HTMLElement | null;
if (target && target.getBoundingClientRect) {
anchorRect = target.getBoundingClientRect();
} else {
const editableEl = this.editable?.nativeElement;
// Try to anchor on the inline toolbar container so the menu appears near the toolbar buttons.
const toolbarEl = editableEl?.closest('app-block-inline-toolbar') as HTMLElement | null;
if (toolbarEl) {
anchorRect = toolbarEl.getBoundingClientRect();
} else if (editableEl) {
anchorRect = editableEl.getBoundingClientRect();
} }
} }
// ESC closes dropdown // Measure panel size (after initial render) for accurate clamping.
if (event.key === 'Escape' && this.moreOpen()) { const panelRect = panel?.getBoundingClientRect();
event.preventDefault(); const width = panelRect?.width ?? 420;
this.moreOpen.set(false); const height = panelRect?.height ?? 0;
return;
// Default position: below and horizontally aligned with the anchor.
let top = (anchorRect?.bottom ?? 0) + 8;
let left: number;
if (event) {
// When opened from a specific button (e.g. "+" in columns), align from its left edge.
left = anchorRect?.left ?? 0;
} else {
// When opened from the inline toolbar / keyboard, align the menu to the right side
// of the toolbar so it appears beside the toolbar buttons (like the context menu).
const right = anchorRect ? anchorRect.right : vw;
left = right - width;
} }
// ArrowUp/ArrowDown navigation between blocks // Horizontal clamp (keep inside viewport with 8px margin).
if (event.key === 'ArrowUp' || event.key === 'ArrowDown') { if (left + width > vw - 8) {
const el = (event.target as HTMLElement); left = Math.max(8, vw - width - 8);
const text = el.textContent || ''; }
const sel = window.getSelection(); if (left < 8) {
if (!sel) return; left = 8;
}
const atStart = sel.anchorOffset === 0; // Vertical clamp: open upwards if there is not enough space below.
const atEnd = sel.anchorOffset === text.length; if (top + height > vh - 8 && anchorRect) {
top = anchorRect.top - height - 8;
if (event.key === 'ArrowUp' && atStart) {
event.preventDefault();
this.focusSibling(-1);
}
if (event.key === 'ArrowDown' && atEnd) {
event.preventDefault();
this.focusSibling(1);
} }
if (top < 8) {
top = 8;
} }
this.menuTop.set(Math.round(top));
this.menuLeft.set(Math.round(left));
try { panel?.focus(); } catch {}
}, 0);
} }
onBlur(): void { onBlur(): void {
this.isFocused.set(false); // ... (rest of the code remains the same)
// Recompute emptiness in case content was cleared // Recompute emptiness in case content was cleared
const el = this.editable?.nativeElement; const el = this.editable?.nativeElement;
if (el) this.isEmpty.set(!(el.textContent && el.textContent.length > 0)); if (el) this.isEmpty.set(!(el.textContent && el.textContent.length > 0));
@ -369,48 +408,64 @@ export class ParagraphBlockComponent implements AfterViewInit {
} }
onContainerClick(event: MouseEvent): void { onContainerClick(event: MouseEvent): void {
// Ignore clicks on buttons/icons to avoid stealing clicks // If clicking inside the editable content, let browser place the caret naturally
const target = event.target as HTMLElement; const target = event.target as HTMLElement;
if (target.closest('[contenteditable]')) return;
if (target.closest('button')) return; if (target.closest('button')) return;
const el = this.editable?.nativeElement; const el = this.editable?.nativeElement;
if (!el) return; if (!el) return;
// Focus and place caret at start so cursor blinks before placeholder // Focus and place caret at END when clicking the container area
el.focus(); el.focus();
const sel = window.getSelection(); const sel = window.getSelection();
if (!sel) return; if (!sel) return;
const range = document.createRange(); const range = document.createRange();
range.selectNodeContents(el); range.selectNodeContents(el);
range.collapse(true); // start range.collapse(false); // end
sel.removeAllRanges(); sel.removeAllRanges();
sel.addRange(range); sel.addRange(range);
this.isFocused.set(true); this.isFocused.set(true);
} }
private focusSibling(delta: number): void { private focusSibling(delta: number): void {
const blocks = this.documentService.blocks(); const blocksEls = Array.from(document.querySelectorAll('[data-block-id]')) as HTMLElement[];
const idx = blocks.findIndex(b => b.id === this.block.id); const host = this.editable?.nativeElement.closest('[data-block-id]') as HTMLElement | null;
const next = blocks[idx + delta]; if (!host) return;
if (!next) return; const idx = blocksEls.findIndex(b => b === host);
this.selectionService.setActive(next.id); if (idx === -1) return;
setTimeout(() => {
const nextEl = document.querySelector(`[data-block-id="${next.id}"] [contenteditable]`) as HTMLElement | null;
nextEl?.focus();
}, 0);
}
private openMenu(): void { let i = idx + delta;
this.moreOpen.set(true); while (i >= 0 && i < blocksEls.length) {
try { this.selectionService.setActive(this.block.id); } catch {} const candidate = blocksEls[i];
// Compute viewport position near the editable content const nextEditable = candidate.querySelector('input[type="text"], textarea, [contenteditable]') as HTMLElement | null;
if (nextEditable) {
const id = candidate.getAttribute('data-block-id');
if (id) {
try { this.selectionService.setActive(id); } catch {}
}
setTimeout(() => { setTimeout(() => {
const el = this.editable?.nativeElement; nextEditable.focus();
const rect = el?.getBoundingClientRect(); const stored = (window as any).__obsiviewerCaretColumn;
const top = (rect?.bottom ?? 0) + 8;
const left = Math.max(8, Math.min((rect?.left ?? 0), window.innerWidth - 440));
this.menuTop.set(Math.round(top));
this.menuLeft.set(Math.round(left));
try { this.menuPanel?.nativeElement.focus(); } catch {}
}, 0);
}
if (nextEditable instanceof HTMLInputElement || nextEditable instanceof HTMLTextAreaElement) {
const len = nextEditable.value.length;
const pos = typeof stored === 'number' ? Math.max(0, Math.min(len, stored)) : len;
nextEditable.setSelectionRange(pos, pos);
} else {
const sel = window.getSelection();
if (!sel) return;
const range = document.createRange();
const text = nextEditable.textContent || '';
const desired = typeof stored === 'number' ? stored : text.length;
const offset = Math.max(0, Math.min(text.length, desired));
range.setStart(nextEditable.firstChild || nextEditable, offset);
range.collapse(true);
sel.removeAllRanges();
sel.addRange(range);
}
}, 0);
return;
}
i += delta;
}
}
} }

View File

@ -209,12 +209,19 @@ export class EditorShellComponent implements AfterViewInit {
showInitialMenu = signal(false); showInitialMenu = signal(false);
private insertAfterBlockId = signal<string | null>(null); private insertAfterBlockId = signal<string | null>(null);
ngOnInit(): void { async ngOnInit(): Promise<void> {
// Try to load from localStorage // Try to load from the vault first (tests/nimbus-editor-snapshot.md)
const loaded = this.documentService.loadFromLocalStorage(); let loaded = await this.documentService.loadFromVault();
if (!loaded) {
// Legacy fallback: localStorage backup for the Nimbus test editor.
loaded = this.documentService.loadFromLocalStorage();
}
if (!loaded) { if (!loaded) {
this.documentService.createNew('Welcome to Nimbus Editor'); this.documentService.createNew('Welcome to Nimbus Editor');
} }
// Always start at top of page for the editor view // Always start at top of page for the editor view
try { window.scrollTo({ top: 0, behavior: 'auto' }); } catch {} try { window.scrollTo({ top: 0, behavior: 'auto' }); } catch {}
} }

View File

@ -1,4 +1,4 @@
import { Component, inject, Output, EventEmitter, signal, computed, ViewChild, ElementRef, effect } from '@angular/core'; import { Component, inject, Output, EventEmitter, signal, computed, ViewChild, ElementRef, effect, HostListener } 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';
@ -10,10 +10,12 @@ import { PaletteItem, PaletteCategory, getPaletteItemsByCategory } from '../../c
imports: [CommonModule, FormsModule], imports: [CommonModule, FormsModule],
template: ` template: `
@if (paletteService.isOpen()) { @if (paletteService.isOpen()) {
<div class="fixed inset-0 z-[9999] flex items-start justify-center pt-32" (click)="close()"> <div class="fixed inset-0 z-[9999]" (click)="close()">
<div <div
#menuPanel #menuPanel
class="bg-surface1 rounded-2xl shadow-surface-md border border-app w-[520px] max-h-[600px] overflow-hidden flex flex-col" class="bg-surface1 rounded-2xl shadow-surface-md border border-app w-[520px] max-h-[600px] overflow-hidden flex flex-col fixed"
[style.left.px]="left"
[style.top.px]="top"
(click)="$event.stopPropagation()" (click)="$event.stopPropagation()"
> >
<!-- Header collapsible --> <!-- Header collapsible -->
@ -169,6 +171,9 @@ export class BlockMenuComponent {
showSuggestions = signal(true); showSuggestions = signal(true);
selectedItem = signal<PaletteItem | null>(null); selectedItem = signal<PaletteItem | null>(null);
left = 0;
top = 0;
categories: PaletteCategory[] = [ categories: PaletteCategory[] = [
'BASIC', 'BASIC',
'ADVANCED', 'ADVANCED',
@ -194,6 +199,70 @@ export class BlockMenuComponent {
} }
}); });
private _positionEffect = effect(() => {
const isOpen = this.paletteService.isOpen();
if (!isOpen) return;
setTimeout(() => {
try { this.reposition(); } catch {}
}, 0);
});
@HostListener('window:resize')
onWindowResize(): void {
if (this.paletteService.isOpen()) {
this.reposition();
}
}
@HostListener('window:scroll')
onWindowScroll(): void {
if (this.paletteService.isOpen()) {
this.reposition();
}
}
private reposition(): void {
const panel = this.menuPanel?.nativeElement;
if (!panel) return;
const vw = window.innerWidth;
const vh = window.innerHeight;
const rect = panel.getBoundingClientRect();
const explicit = this.paletteService.position();
const triggerId = this.paletteService.triggerBlockId();
let left = explicit?.left ?? 0;
let top = explicit?.top ?? 0;
if (!explicit) {
let anchored = false;
if (triggerId) {
const triggerEl = document.querySelector(`[data-block-id="${triggerId}"]`) as HTMLElement | null;
if (triggerEl) {
const r = triggerEl.getBoundingClientRect();
left = r.left;
top = r.bottom + 8;
anchored = true;
}
}
if (!anchored) {
left = (vw - rect.width) / 2;
top = (vh - rect.height) / 2;
}
}
if (left + rect.width > vw - 8) left = Math.max(8, vw - rect.width - 8);
if (left < 8) left = 8;
if (top + rect.height > vh - 8) {
top = Math.max(8, top - rect.height);
}
if (top < 8) top = 8;
this.left = left;
this.top = top;
}
toggleSuggestions(): void { toggleSuggestions(): void {
this.showSuggestions.update(v => !v); this.showSuggestions.update(v => !v);
// If suggestions become visible while open, focus the input // If suggestions become visible while open, focus the input

View File

@ -0,0 +1,50 @@
import { Injectable } from '@angular/core';
import { BlockType } from '../core/models/block.model';
export interface MenuItem {
type: BlockType;
preset: any;
icon: string;
label: string;
shortcut: string;
category: string;
}
@Injectable({
providedIn: 'root'
})
export class BlockMenuStylingService {
private readonly MENU_STRUCTURE: MenuItem[] = [
// BASIC
{ type: 'paragraph' as BlockType, preset: null, icon: '¶', label: 'Paragraph', shortcut: 'ctrl+alt+0', category: 'BASIC' },
{ type: 'heading' as BlockType, preset: { level: 1 }, icon: 'H₁', label: 'Heading 1', shortcut: 'ctrl+alt+1', category: 'BASIC' },
{ type: 'heading' as BlockType, preset: { level: 2 }, icon: 'H₂', label: 'Heading 2', shortcut: 'ctrl+alt+2', category: 'BASIC' },
{ type: 'heading' as BlockType, preset: { level: 3 }, icon: 'H₃', label: 'Heading 3', shortcut: 'ctrl+alt+3', category: 'BASIC' },
{ type: 'list-item' as BlockType, preset: { kind: 'bullet', text: '' }, icon: '•', label: 'Bullet list', shortcut: 'ctrl+shift+8', category: 'BASIC' },
{ type: 'list-item' as BlockType, preset: { kind: 'numbered', number: 1, text: '' }, icon: '1.', label: 'Numbered list', shortcut: 'ctrl+shift+7', category: 'BASIC' },
{ type: 'list-item' as BlockType, preset: { kind: 'check', checked: false, text: '' }, icon: '☑️', label: 'Checkbox list', shortcut: 'ctrl+shift+9', category: 'BASIC' },
{ type: 'toggle' as BlockType, preset: null, icon: '▶️', label: 'Toggle Block', shortcut: 'ctrl+alt+t', category: 'BASIC' },
// ADVANCED
{ type: 'code' as BlockType, preset: null, icon: '</>', label: 'Code', shortcut: 'ctrl+alt+c', category: 'ADVANCED' },
{ type: 'quote' as BlockType, preset: null, icon: '❝', label: 'Quote', shortcut: 'ctrl+"', category: 'ADVANCED' },
{ type: 'hint' as BlockType, preset: null, icon: '', label: 'Hint', shortcut: 'ctrl+alt+u', category: 'ADVANCED' },
{ type: 'button' as BlockType, preset: null, icon: '🔘', label: 'Button', shortcut: 'ctrl+alt+b', category: 'ADVANCED' },
{ type: 'steps' as BlockType, preset: null, icon: '📝', label: 'Steps', shortcut: '', category: 'ADVANCED' },
];
getConvertOptions() {
return this.MENU_STRUCTURE;
}
getGroupedConvertOptions() {
return this.MENU_STRUCTURE.reduce((acc, item) => {
if (!acc[item.category]) {
acc[item.category] = [];
}
acc[item.category].push(item);
return acc;
}, {} as Record<string, MenuItem[]>);
}
}

View File

@ -1,7 +1,8 @@
import { Injectable, signal, computed, effect } from '@angular/core'; import { Injectable, signal, computed, effect, inject } from '@angular/core';
import { moveItemImmutable } from '../core/utils/reorder'; import { moveItemImmutable } from '../core/utils/reorder';
import { Block, BlockType, DocumentModel, HeadingProps, OutlineHeading } from '../core/models/block.model'; import { Block, BlockType, DocumentModel, HeadingProps, OutlineHeading } from '../core/models/block.model';
import { generateId } from '../core/utils/id-generator'; import { generateId } from '../core/utils/id-generator';
import { VaultService } from '../../../services/vault.service';
/** /**
* Document state management service * Document state management service
@ -21,6 +22,16 @@ export class DocumentService {
private _saveTimeout: any; private _saveTimeout: any;
private readonly SAVE_DEBOUNCE = 750; private readonly SAVE_DEBOUNCE = 750;
// Nimbus Editor persistence configuration (Section Tests)
// We persist the Nimbus DocumentModel snapshot as a Markdown file under tests/
// using a JSON code block instead of a raw .json file because the generic
// /api/files endpoint only accepts text/markdown (or Excalidraw JSON) for writes.
// This keeps the format consistent with the rest of the vault and leverages
// the existing VaultService.saveMarkdown() helper.
private readonly NIMBUS_TEST_FILE_PATH = 'tests/nimbus-editor-snapshot.md';
private readonly vaultService = inject(VaultService);
// Public signals // Public signals
readonly doc = this._doc.asReadonly(); readonly doc = this._doc.asReadonly();
readonly saveState = this._saveState.asReadonly(); readonly saveState = this._saveState.asReadonly();
@ -567,6 +578,36 @@ export class DocumentService {
} }
} }
/**
* Load Nimbus Editor document from the vault (tests/nimbus-editor-snapshot.md).
* Returns true when a valid DocumentModel was found and loaded.
*/
async loadFromVault(): Promise<boolean> {
try {
const url = `/api/files?path=${encodeURIComponent(this.NIMBUS_TEST_FILE_PATH)}`;
const res = await fetch(url);
if (!res.ok) {
if (res.status !== 404) {
console.error('[DocumentService] Failed to load Nimbus document from vault:', res.status, res.statusText);
}
return false;
}
const content = await res.text();
const doc = this.parseDocumentFromMarkdown(content);
if (!doc) {
console.warn('[DocumentService] Nimbus vault file exists but could not be parsed, falling back.');
return false;
}
this.load(doc);
return true;
} catch (error) {
console.error('[DocumentService] Error while loading Nimbus document from vault:', error);
return false;
}
}
/** /**
* Schedule save (debounced) * Schedule save (debounced)
*/ */
@ -577,25 +618,116 @@ export class DocumentService {
this._saveState.set('saving'); this._saveState.set('saving');
this._saveTimeout = setTimeout(() => { this._saveTimeout = setTimeout(() => {
this.saveToLocalStorage(snapshot); // Persist to the vault for the "Éditeur Nimbus — Section Tests" test page,
// while still keeping a localStorage backup for safety.
void this.saveToVaultWithBackup(snapshot);
}, this.SAVE_DEBOUNCE); }, this.SAVE_DEBOUNCE);
} }
/** /**
* Save to localStorage * Persist the current snapshot to the vault, with a best-effort localStorage backup.
*
* On success, saveState is set to 'saved'. On any error, saveState is set to 'error'
* but we still try to keep a local backup to reduce the risk of total data loss.
*/ */
private saveToLocalStorage(doc: DocumentModel): void { private async saveToVaultWithBackup(doc: DocumentModel): Promise<void> {
try { try {
localStorage.setItem('nimbus-editor-doc', JSON.stringify(doc)); await this.saveToVault(doc);
this.saveToLocalStorage(doc);
this._saveState.set('saved'); this._saveState.set('saved');
} catch (error) { } catch (error) {
console.error('Failed to save document:', error); console.error('[DocumentService] Failed to save Nimbus document to vault:', error);
// Still keep a best-effort local backup so the user does not lose work completely.
this.saveToLocalStorage(doc);
this._saveState.set('error'); this._saveState.set('error');
} }
} }
/** /**
* Load from localStorage * Save the Nimbus DocumentModel snapshot to the vault as a Markdown file
* under tests/, containing a JSON code block with the serialized model.
*/
private async saveToVault(doc: DocumentModel): Promise<void> {
const markdown = this.serializeDocumentToMarkdown(doc);
const vault: any = this.vaultService as any;
const ok = await vault.saveMarkdown(this.NIMBUS_TEST_FILE_PATH, markdown);
if (!ok) {
throw new Error('VaultService.saveMarkdown returned false for Nimbus editor snapshot');
}
}
/**
* Serialize the in-memory DocumentModel to a Markdown document that embeds
* the full JSON snapshot in a fenced code block.
*/
private serializeDocumentToMarkdown(doc: DocumentModel): string {
const json = JSON.stringify(doc, null, 2);
const lines: string[] = [];
lines.push('---');
lines.push('title: "Éditeur Nimbus — Section Tests"');
lines.push('nimbusEditor: true');
lines.push('documentModelFormat: "block-model-v1"');
lines.push('---');
lines.push('');
lines.push('```json');
lines.push(json);
lines.push('```');
lines.push('');
return lines.join('\n');
}
/**
* Extract a DocumentModel snapshot from the Markdown representation used
* for the Nimbus Editor test page. If parsing fails, returns null.
*/
private parseDocumentFromMarkdown(content: string): DocumentModel | null {
try {
const marker = '```json';
const start = content.indexOf(marker);
if (start === -1) {
return null;
}
const afterMarker = content.indexOf('\n', start);
if (afterMarker === -1) {
return null;
}
const endFence = content.indexOf('```', afterMarker + 1);
if (endFence === -1) {
return null;
}
const jsonText = content.slice(afterMarker + 1, endFence).trim();
if (!jsonText) {
return null;
}
const parsed = JSON.parse(jsonText);
return parsed as DocumentModel;
} catch (error) {
console.error('[DocumentService] Failed to parse Nimbus document from markdown:', error);
return null;
}
}
/**
* Save a best-effort backup of the current document to localStorage. This is
* used as a safety net in addition to the vault persistence.
*/
private saveToLocalStorage(doc: DocumentModel): void {
try {
localStorage.setItem('nimbus-editor-doc', JSON.stringify(doc));
} catch (error) {
console.error('Failed to save Nimbus document backup to localStorage:', error);
}
}
/**
* Load from localStorage (legacy / offline fallback used when the vault
* snapshot does not exist or cannot be parsed).
*/ */
loadFromLocalStorage(): boolean { loadFromLocalStorage(): boolean {
try { try {
@ -606,13 +738,13 @@ export class DocumentService {
return true; return true;
} }
} catch (error) { } catch (error) {
console.error('Failed to load document:', error); console.error('Failed to load Nimbus document from localStorage:', error);
} }
return false; return false;
} }
/** /**
* Clear localStorage * Clear the legacy localStorage backup.
*/ */
clearLocalStorage(): void { clearLocalStorage(): void {
localStorage.removeItem('nimbus-editor-doc'); localStorage.removeItem('nimbus-editor-doc');

View File

@ -0,0 +1,347 @@
---
title: "Éditeur Nimbus — Section Tests"
nimbusEditor: true
documentModelFormat: "block-model-v1"
---
```json
{
"id": "block_1763149113471_461xyut80",
"title": "Page Tests",
"blocks": [
{
"id": "block_1763234865120_um14zlycy",
"type": "heading",
"props": {
"level": 1,
"text": "H1"
},
"meta": {
"createdAt": "2025-11-15T19:27:45.120Z",
"updatedAt": "2025-11-15T20:06:49.723Z",
"align": "center",
"bgColor": "#dc2626"
}
},
{
"id": "block_1763240860187_hdklpobdf",
"type": "line",
"props": {
"style": "solid"
},
"meta": {
"createdAt": "2025-11-15T21:07:40.187Z",
"updatedAt": "2025-11-15T21:36:29.164Z"
}
},
{
"id": "block_1763234657433_5malb56fe",
"type": "list-item",
"props": {
"kind": "check",
"text": "checkbox",
"checked": false,
"indent": 0,
"align": "left"
},
"meta": {
"createdAt": "2025-11-15T19:24:17.433Z",
"updatedAt": "2025-11-15T20:13:56.404Z",
"bgColor": "#dc2626"
}
},
{
"id": "block_1763237180130_m82opx9yx",
"type": "paragraph",
"props": {
"text": "paragraphe"
},
"meta": {
"createdAt": "2025-11-15T20:06:20.130Z",
"updatedAt": "2025-11-15T20:13:50.464Z",
"bgColor": "#dc2626"
}
},
{
"id": "block_1763234665227_e09ql6sb4",
"type": "list-item",
"props": {
"kind": "bullet",
"text": "bullet",
"indent": 0,
"align": "left"
},
"meta": {
"createdAt": "2025-11-15T19:24:25.227Z",
"updatedAt": "2025-11-15T20:07:27.797Z",
"bgColor": "#dc2626"
}
},
{
"id": "block_1763237840896_fuw5hvm9t",
"type": "columns",
"props": {
"columns": [
{
"id": "or66s9hqb",
"blocks": [
{
"id": "block_1763237836743_a06ez4lux",
"type": "paragraph",
"props": {
"text": "paragraphe"
},
"meta": {
"createdAt": "2025-11-15T20:17:16.743Z",
"updatedAt": "2025-11-15T20:17:16.743Z",
"bgColor": "#dc2626"
}
},
{
"id": "n90fsx72s",
"type": "paragraph",
"props": {
"text": "paragraphe"
},
"children": [],
"meta": {
"bgColor": "#dc2626"
}
},
{
"id": "tkk3ir62w",
"type": "paragraph",
"props": {
"text": ""
},
"children": []
},
{
"id": "ljyumvc5w",
"type": "paragraph",
"props": {
"text": ""
},
"children": []
},
{
"id": "rae60bsx3",
"type": "paragraph",
"props": {
"text": ""
},
"children": []
},
{
"id": "xa3p8sybb",
"type": "paragraph",
"props": {
"text": ""
},
"children": []
},
{
"id": "qln2xcscy",
"type": "paragraph",
"props": {
"text": ""
},
"children": []
}
],
"width": 50
},
{
"id": "s16nsirzh",
"blocks": [
{
"id": "block_1763237223906_8fazui2p9",
"type": "paragraph",
"props": {
"text": "paragraphe"
},
"meta": {
"createdAt": "2025-11-15T20:07:03.906Z",
"updatedAt": "2025-11-15T20:07:24.349Z",
"bgColor": "#dc2626"
}
}
],
"width": 50
}
]
},
"meta": {
"createdAt": "2025-11-15T20:17:20.896Z",
"updatedAt": "2025-11-15T22:13:34.707Z"
}
},
{
"id": "block_1763237641170_alnjnsj8c",
"type": "list-item",
"props": {
"kind": "numbered",
"text": "number list 1",
"indent": 0,
"align": "left",
"number": 1
},
"meta": {
"createdAt": "2025-11-15T20:14:01.171Z",
"updatedAt": "2025-11-15T20:14:14.163Z",
"bgColor": "#dc2626"
}
},
{
"id": "block_1763238667046_3xvnp49yo",
"type": "heading",
"props": {
"level": 1,
"text": "H2"
},
"meta": {
"createdAt": "2025-11-15T20:31:07.046Z",
"updatedAt": "2025-11-15T21:07:39.675Z",
"bgColor": "#525252"
}
},
{
"id": "block_1763238706236_v3kaqyax7",
"type": "heading",
"props": {
"level": 1,
"text": ""
},
"meta": {
"createdAt": "2025-11-15T20:31:46.236Z",
"updatedAt": "2025-11-15T21:55:37.233Z"
}
},
{
"id": "block_1763238706552_9zpoiafug",
"type": "table",
"props": {
"rows": [
{
"id": "block_1763248205146_pnw8cifrx",
"cells": [
{
"id": "block_1763248205146_p9wc7se4g",
"text": ""
}
]
}
],
"header": false
},
"meta": {
"createdAt": "2025-11-15T20:31:46.552Z",
"updatedAt": "2025-11-15T23:10:05.176Z"
}
},
{
"id": "block_1763238706720_x5fdjzcm1",
"type": "paragraph",
"props": {
"text": ""
},
"meta": {
"createdAt": "2025-11-15T20:31:46.720Z",
"updatedAt": "2025-11-15T20:32:24.875Z"
}
},
{
"id": "block_1763238706867_cbgv0yetb",
"type": "paragraph",
"props": {
"text": ""
},
"meta": {
"createdAt": "2025-11-15T20:31:46.867Z",
"updatedAt": "2025-11-15T20:31:46.867Z"
}
},
{
"id": "block_1763238707162_bh255zvaf",
"type": "paragraph",
"props": {
"text": ""
},
"meta": {
"createdAt": "2025-11-15T20:31:47.162Z",
"updatedAt": "2025-11-15T20:31:47.162Z"
}
},
{
"id": "block_1763248197366_ynjr796za",
"type": "paragraph",
"props": {
"text": ""
},
"meta": {
"createdAt": "2025-11-15T23:09:57.366Z",
"updatedAt": "2025-11-15T23:09:57.366Z"
}
},
{
"id": "block_1763248197834_sb477zyix",
"type": "paragraph",
"props": {
"text": ""
},
"meta": {
"createdAt": "2025-11-15T23:09:57.834Z",
"updatedAt": "2025-11-15T23:09:57.834Z"
}
},
{
"id": "block_1763248198129_38lauudq6",
"type": "paragraph",
"props": {
"text": ""
},
"meta": {
"createdAt": "2025-11-15T23:09:58.130Z",
"updatedAt": "2025-11-15T23:09:58.130Z"
}
},
{
"id": "block_1763248199064_xs3run960",
"type": "paragraph",
"props": {
"text": ""
},
"meta": {
"createdAt": "2025-11-15T23:09:59.064Z",
"updatedAt": "2025-11-15T23:09:59.064Z"
}
},
{
"id": "block_1763238707334_uupqjbbia",
"type": "paragraph",
"props": {
"text": ""
},
"meta": {
"createdAt": "2025-11-15T20:31:47.334Z",
"updatedAt": "2025-11-15T20:31:47.334Z"
}
},
{
"id": "block_1763238707506_lj73550jb",
"type": "paragraph",
"props": {
"text": ""
},
"meta": {
"createdAt": "2025-11-15T20:31:47.506Z",
"updatedAt": "2025-11-15T20:31:47.506Z"
}
}
],
"meta": {
"createdAt": "2025-11-14T19:38:33.471Z",
"updatedAt": "2025-11-15T23:10:05.176Z"
}
}
```