```
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:
parent
9887be548e
commit
ba86bd4b91
@ -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()"
|
||||||
>
|
>
|
||||||
<button
|
@for (group of groupedConvertOptions | keyvalue; track group.key) {
|
||||||
*ngFor="let item of convertOptions"
|
<div class="mb-1.5 last:mb-0">
|
||||||
class="w-full text-left px-4 py-2 hover:bg-surface2 dark:hover:bg-gray-700 transition flex items-center justify-between"
|
<div class="text-[10px] font-semibold tracking-wide text-text-muted uppercase px-3 pt-2 pb-1">{{ group.key }}</div>
|
||||||
(mousedown)="onConvert(item.type, item.preset)"
|
@for (item of group.value; track item.label) {
|
||||||
(click)="$event.preventDefault()"
|
<button
|
||||||
>
|
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"
|
||||||
<div class="flex items-center gap-3">
|
(mousedown)="onConvert(item.type, item.preset)"
|
||||||
<span class="text-base w-5 flex items-center justify-center">
|
(click)="$event.preventDefault()"
|
||||||
@if (item.type === 'list-item' && item.preset?.kind === 'check') {
|
>
|
||||||
<svg class="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
<div class="flex items-center gap-2.5">
|
||||||
<rect x="3" y="3" width="5" height="5" rx="1"/>
|
<span class="text-sm w-5 h-5 flex items-center justify-center text-text-muted">{{ item.icon }}</span>
|
||||||
<path d="M10 6h10"/>
|
<span class="text-text-main truncate">{{ item.label }}</span>
|
||||||
<rect x="3" y="10" width="5" height="5" rx="1"/>
|
</div>
|
||||||
<path d="M10 13h10"/>
|
<span class="shortcut-key">{{ item.shortcut }}</span>
|
||||||
<rect x="3" y="17" width="5" height="5" rx="1"/>
|
</button>
|
||||||
<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>
|
}
|
||||||
</button>
|
|
||||||
</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) {
|
|
||||||
this.left = this.position.x;
|
if (changes['position'] && this.visible) {
|
||||||
this.top = this.position.y;
|
this.open();
|
||||||
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 = [
|
||||||
|
|||||||
@ -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()">
|
||||||
|
|||||||
@ -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"
|
||||||
>
|
>
|
||||||
|
|||||||
@ -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 });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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;
|
||||||
event.preventDefault();
|
|
||||||
this.focusSibling(-1);
|
if (event.key === 'ArrowUp') {
|
||||||
|
if (singleLine || atStart) {
|
||||||
|
event.preventDefault();
|
||||||
|
this.focusSibling(-1);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (event.key === 'ArrowDown' && atEnd) {
|
|
||||||
event.preventDefault();
|
if (event.key === 'ArrowDown') {
|
||||||
this.focusSibling(1);
|
if (singleLine || atEnd) {
|
||||||
|
event.preventDefault();
|
||||||
|
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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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'])
|
||||||
|
|||||||
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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
|
private openMenu(event?: MouseEvent): void {
|
||||||
if (event.key === 'Enter' && event.shiftKey) {
|
this.moreOpen.set(true);
|
||||||
// Default behavior - line break within block
|
try { this.selectionService.setActive(this.block.id); } catch {}
|
||||||
return;
|
// Compute viewport-safe position near the trigger icon (if provided),
|
||||||
}
|
// otherwise fall back to the editable paragraph like the old behavior.
|
||||||
|
setTimeout(() => {
|
||||||
|
const vw = window.innerWidth;
|
||||||
|
const vh = window.innerHeight;
|
||||||
|
const panel = this.menuPanel?.nativeElement;
|
||||||
|
|
||||||
// Handle BACKSPACE on empty block: open dropdown instead of delete
|
// Determine anchor rectangle: trigger element first, then inline toolbar, then editable block.
|
||||||
if (event.key === 'Backspace') {
|
let anchorRect: DOMRect | null = null;
|
||||||
const target = event.target as HTMLElement;
|
const target = (event?.currentTarget || event?.target) as HTMLElement | null;
|
||||||
const selection = window.getSelection();
|
if (target && target.getBoundingClientRect) {
|
||||||
if (selection && selection.anchorOffset === 0 && (!target.textContent || target.textContent.length === 0)) {
|
anchorRect = target.getBoundingClientRect();
|
||||||
event.preventDefault();
|
} else {
|
||||||
this.openMenu();
|
const editableEl = this.editable?.nativeElement;
|
||||||
return;
|
// 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;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ArrowUp/ArrowDown navigation between blocks
|
// Default position: below and horizontally aligned with the anchor.
|
||||||
if (event.key === 'ArrowUp' || event.key === 'ArrowDown') {
|
let top = (anchorRect?.bottom ?? 0) + 8;
|
||||||
const el = (event.target as HTMLElement);
|
let left: number;
|
||||||
const text = el.textContent || '';
|
|
||||||
const sel = window.getSelection();
|
|
||||||
if (!sel) return;
|
|
||||||
|
|
||||||
const atStart = sel.anchorOffset === 0;
|
if (event) {
|
||||||
const atEnd = sel.anchorOffset === text.length;
|
// When opened from a specific button (e.g. "+" in columns), align from its left edge.
|
||||||
|
left = anchorRect?.left ?? 0;
|
||||||
if (event.key === 'ArrowUp' && atStart) {
|
} else {
|
||||||
event.preventDefault();
|
// When opened from the inline toolbar / keyboard, align the menu to the right side
|
||||||
this.focusSibling(-1);
|
// of the toolbar so it appears beside the toolbar buttons (like the context menu).
|
||||||
|
const right = anchorRect ? anchorRect.right : vw;
|
||||||
|
left = right - width;
|
||||||
}
|
}
|
||||||
if (event.key === 'ArrowDown' && atEnd) {
|
|
||||||
event.preventDefault();
|
// Horizontal clamp (keep inside viewport with 8px margin).
|
||||||
this.focusSibling(1);
|
if (left + width > vw - 8) {
|
||||||
|
left = Math.max(8, vw - width - 8);
|
||||||
}
|
}
|
||||||
}
|
if (left < 8) {
|
||||||
|
left = 8;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Vertical clamp: open upwards if there is not enough space below.
|
||||||
|
if (top + height > vh - 8 && anchorRect) {
|
||||||
|
top = anchorRect.top - height - 8;
|
||||||
|
}
|
||||||
|
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;
|
||||||
setTimeout(() => {
|
if (nextEditable) {
|
||||||
const el = this.editable?.nativeElement;
|
const id = candidate.getAttribute('data-block-id');
|
||||||
const rect = el?.getBoundingClientRect();
|
if (id) {
|
||||||
const top = (rect?.bottom ?? 0) + 8;
|
try { this.selectionService.setActive(id); } catch {}
|
||||||
const left = Math.max(8, Math.min((rect?.left ?? 0), window.innerWidth - 440));
|
}
|
||||||
this.menuTop.set(Math.round(top));
|
setTimeout(() => {
|
||||||
this.menuLeft.set(Math.round(left));
|
nextEditable.focus();
|
||||||
try { this.menuPanel?.nativeElement.focus(); } catch {}
|
const stored = (window as any).__obsiviewerCaretColumn;
|
||||||
}, 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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 {}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
50
src/app/editor/services/block-menu-styling.service.ts
Normal file
50
src/app/editor/services/block-menu-styling.service.ts
Normal 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[]>);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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');
|
||||||
|
|||||||
347
vault/tests/nimbus-editor-snapshot.md
Normal file
347
vault/tests/nimbus-editor-snapshot.md
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
Loading…
x
Reference in New Issue
Block a user