@qalma/kit 0.1.0-beta.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,61 +1,14 @@
1
- import { clsx } from 'clsx';
2
- import { twMerge } from 'tailwind-merge';
1
+ import { DismissibleOverlay, QalmaSuggestionMenu } from '@qalma/kit/headless';
2
+ export * from '@qalma/kit/headless';
3
3
  import * as i0 from '@angular/core';
4
- import { inject, ElementRef, input, computed, Directive, ChangeDetectionStrategy, Component, signal, DestroyRef, afterNextRender, output, effect } from '@angular/core';
5
- import { cva } from 'class-variance-authority';
4
+ import { input, ChangeDetectionStrategy, Component, computed, inject, ElementRef, Directive, output, signal, DestroyRef, effect } from '@angular/core';
6
5
  import { NgIcon, provideIcons } from '@ng-icons/core';
7
6
  import { QalmaCommand, QalmaToolbar } from '@qalma/editor';
8
7
  import { NgTemplateOutlet } from '@angular/common';
9
8
  import { lucideUnlink, lucideUndo2, lucideUnderline, lucideTrash2, lucideTextQuote, lucideTable, lucideSuperscript, lucideSubscript, lucideStrikethrough, lucideSquareCode, lucideRows3, lucideRedo2, lucidePilcrow, lucideOutdent, lucideListTodo, lucideListOrdered, lucideList, lucideLetterText, lucideItalic, lucideIndent, lucideHeading3, lucideHeading2, lucideHeading1, lucideHeading, lucideEraser, lucideColumns3, lucideCode, lucideBold, lucideBetweenVerticalEnd, lucideBetweenHorizontalEnd, lucideAlignRight, lucideAlignLeft, lucideAlignJustify, lucideAlignCenter, lucideMousePointer2, lucideGripVertical, lucideCopy, lucideArrowUp, lucideArrowDown, lucideMinus, lucideX, lucidePencil, lucideLink, lucideExternalLink, lucideCheck } from '@ng-icons/lucide';
10
-
11
- function cn(...inputs) {
12
- return twMerge(clsx(inputs));
13
- }
14
-
15
- const buttonVariants = cva('inline-flex cursor-pointer items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:pointer-events-none disabled:opacity-50 [&_ng-icon]:shrink-0', {
16
- variants: {
17
- variant: {
18
- default: 'bg-primary text-primary-foreground hover:bg-primary/90',
19
- secondary: 'bg-secondary text-secondary-foreground hover:bg-secondary/80',
20
- outline: 'border border-border bg-card text-foreground hover:bg-secondary hover:text-secondary-foreground',
21
- ghost: 'text-foreground hover:bg-secondary hover:text-secondary-foreground',
22
- accent: 'bg-accent text-accent-foreground hover:bg-accent/90',
23
- link: 'text-accent underline-offset-4 hover:underline',
24
- },
25
- size: {
26
- default: 'h-9 px-4 py-2',
27
- sm: 'h-8 rounded-md px-3 text-xs',
28
- lg: 'h-11 rounded-md px-6 text-base',
29
- icon: 'h-9 w-9',
30
- },
31
- },
32
- defaultVariants: {
33
- variant: 'default',
34
- size: 'default',
35
- },
36
- });
37
- /**
38
- * Headless-styled button directive so it composes onto native `<button>` and
39
- * `<a>` elements while reading from the host's Tailwind design tokens.
40
- */
41
- class QalmaButton {
42
- host = inject(ElementRef);
43
- initialClass = this.host.nativeElement.getAttribute('class') ?? '';
44
- variant = input('default', ...(ngDevMode ? [{ debugName: "variant" }] : /* istanbul ignore next */ []));
45
- size = input('default', ...(ngDevMode ? [{ debugName: "size" }] : /* istanbul ignore next */ []));
46
- computedClass = computed(() => cn(buttonVariants({ variant: this.variant(), size: this.size() }), this.initialClass), ...(ngDevMode ? [{ debugName: "computedClass" }] : /* istanbul ignore next */ []));
47
- static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.17", ngImport: i0, type: QalmaButton, deps: [], target: i0.ɵɵFactoryTarget.Directive });
48
- static ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "17.1.0", version: "21.2.17", type: QalmaButton, isStandalone: true, selector: "button[qalmaBtn], a[qalmaBtn]", inputs: { variant: { classPropertyName: "variant", publicName: "variant", isSignal: true, isRequired: false, transformFunction: null }, size: { classPropertyName: "size", publicName: "size", isSignal: true, isRequired: false, transformFunction: null } }, host: { properties: { "class": "computedClass()" } }, ngImport: i0 });
49
- }
50
- i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.17", ngImport: i0, type: QalmaButton, decorators: [{
51
- type: Directive,
52
- args: [{
53
- selector: 'button[qalmaBtn], a[qalmaBtn]',
54
- host: {
55
- '[class]': 'computedClass()',
56
- },
57
- }]
58
- }], propDecorators: { variant: [{ type: i0.Input, args: [{ isSignal: true, alias: "variant", required: false }] }], size: [{ type: i0.Input, args: [{ isSignal: true, alias: "size", required: false }] }] } });
9
+ import { cva } from 'class-variance-authority';
10
+ import { clsx } from 'clsx';
11
+ import { twMerge } from 'tailwind-merge';
59
12
 
60
13
  /**
61
14
  * Default styling for a toolbar command button. Icon-only, square, and driven
@@ -368,500 +321,54 @@ function provideQalmaToolbarIcons() {
368
321
  });
369
322
  }
370
323
 
371
- const DEFAULT_GAP$1 = 8;
372
- const DEFAULT_EDGE_MARGIN = 8;
373
- function anchorToRect(anchor, options) {
374
- const gap = options.gap ?? DEFAULT_GAP$1;
375
- const edgeMargin = options.edgeMargin ?? DEFAULT_EDGE_MARGIN;
376
- const { boundary, size } = options;
377
- if (options.placement === 'left' || options.placement === 'right') {
378
- const mainAxis = options.placement === 'left'
379
- ? anchor.left - gap - size.width
380
- : anchor.right + gap;
381
- const crossAxis = resolveCrossAxis(options.align, anchor.top, anchor.bottom, size.height);
382
- return {
383
- left: clamp$1(mainAxis, boundary.left + edgeMargin, boundary.right - edgeMargin - size.width),
384
- top: clamp$1(crossAxis, boundary.top + edgeMargin, boundary.bottom - edgeMargin - size.height),
385
- };
386
- }
387
- const mainAxis = options.placement === 'bottom'
388
- ? anchor.bottom + gap
389
- : anchor.top - gap - size.height;
390
- const crossAxis = resolveCrossAxis(options.align, anchor.left, anchor.right, size.width);
391
- return {
392
- top: clamp$1(mainAxis, boundary.top + edgeMargin, boundary.bottom - edgeMargin - size.height),
393
- left: clamp$1(crossAxis, boundary.left + edgeMargin, boundary.right - edgeMargin - size.width),
394
- };
395
- }
396
- function resolveCrossAxis(align, start, end, size) {
397
- const span = end - start;
398
- if (size >= span) {
399
- // The floating element doesn't fit within the anchor (e.g. a
400
- // single-line block shorter than the button anchored to it). Center it
401
- // instead of clamping to an arbitrary edge.
402
- return start + span / 2 - size / 2;
403
- }
404
- if (typeof align === 'number') {
405
- return clamp$1(align - size / 2, start, end - size);
406
- }
407
- switch (align) {
408
- case 'end':
409
- return end - size;
410
- case 'center':
411
- return start + (end - start) / 2 - size / 2;
412
- case 'start':
413
- default:
414
- return start;
415
- }
416
- }
417
- function clamp$1(value, min, max) {
418
- const lo = Math.min(min, max);
419
- const hi = Math.max(min, max);
420
- return Math.min(Math.max(value, lo), hi);
421
- }
422
-
423
- /**
424
- * Shared outside-click / Escape dismiss behavior for floating overlays
425
- * (popovers, contextual menus, autocomplete lists). Generalizes the
426
- * ad-hoc click-outside/Escape handling that used to be reimplemented
427
- * separately per feature.
428
- */
429
- class DismissibleOverlay {
430
- options;
431
- pointerDown = (event) => {
432
- if (!this.options.isInside(event.target)) {
433
- this.options.onDismiss();
434
- }
435
- };
436
- keydown = (event) => {
437
- if (event.key === 'Escape') {
438
- this.options.onDismiss();
439
- }
440
- };
441
- constructor(options) {
442
- this.options = options;
443
- }
444
- connect(destroyRef) {
445
- document.addEventListener('pointerdown', this.pointerDown, true);
446
- document.addEventListener('keydown', this.keydown);
447
- destroyRef.onDestroy(() => {
448
- document.removeEventListener('pointerdown', this.pointerDown, true);
449
- document.removeEventListener('keydown', this.keydown);
450
- });
451
- }
324
+ function cn(...inputs) {
325
+ return twMerge(clsx(inputs));
452
326
  }
453
327
 
328
+ const buttonVariants = cva('inline-flex cursor-pointer items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:pointer-events-none disabled:opacity-50 [&_ng-icon]:shrink-0', {
329
+ variants: {
330
+ variant: {
331
+ default: 'bg-primary text-primary-foreground hover:bg-primary/90',
332
+ secondary: 'bg-secondary text-secondary-foreground hover:bg-secondary/80',
333
+ outline: 'border border-border bg-card text-foreground hover:bg-secondary hover:text-secondary-foreground',
334
+ ghost: 'text-foreground hover:bg-secondary hover:text-secondary-foreground',
335
+ accent: 'bg-accent text-accent-foreground hover:bg-accent/90',
336
+ link: 'text-accent underline-offset-4 hover:underline',
337
+ },
338
+ size: {
339
+ default: 'h-9 px-4 py-2',
340
+ sm: 'h-8 rounded-md px-3 text-xs',
341
+ lg: 'h-11 rounded-md px-6 text-base',
342
+ icon: 'h-9 w-9',
343
+ },
344
+ },
345
+ defaultVariants: {
346
+ variant: 'default',
347
+ size: 'default',
348
+ },
349
+ });
454
350
  /**
455
- * Arrow-key navigation + Enter-to-select for autocomplete-style popovers
456
- * (mention menu, slash command menu). Shared so both features stop
457
- * reimplementing the same wrap-around index math.
458
- *
459
- * Takes a plain key string rather than a `KeyboardEvent` — some callers
460
- * relay keys through a `CustomEvent` (no real `KeyboardEvent` to read),
461
- * so preventing the default action is left to the caller.
351
+ * Headless-styled button directive so it composes onto native `<button>` and
352
+ * `<a>` elements while reading from the host's Tailwind design tokens.
462
353
  */
463
- class KeyboardNavigableList {
464
- options;
465
- constructor(options) {
466
- this.options = options;
467
- }
468
- /** Returns true when the key was handled (caller should stop further propagation / preventDefault). */
469
- handleKey(key) {
470
- const items = this.options.items();
471
- if (items.length === 0) {
472
- return false;
473
- }
474
- switch (key) {
475
- case 'ArrowDown':
476
- this.options.setActiveIndex((this.options.activeIndex() + 1) % items.length);
477
- return true;
478
- case 'ArrowUp':
479
- this.options.setActiveIndex((this.options.activeIndex() - 1 + items.length) % items.length);
480
- return true;
481
- case 'Enter': {
482
- const index = this.options.activeIndex();
483
- const item = items[index];
484
- if (item === undefined) {
485
- return false;
486
- }
487
- this.options.onSelect(item, index);
488
- return true;
489
- }
490
- default:
491
- return false;
492
- }
493
- }
494
- }
495
-
496
- const DRAG_START_DISTANCE = 8;
497
- const HANDLE_EDGE_MARGIN = 8;
498
- const HANDLE_GAP = 8;
499
- const HANDLE_BUTTON_SIZE = 30;
500
- const DROP_LINE_EDGE_MARGIN = 8;
501
- class QalmaDragHandleController {
502
- editor;
503
- handleState = signal(null, ...(ngDevMode ? [{ debugName: "handleState" }] : /* istanbul ignore next */ []));
504
- dropIndicatorState = signal(null, ...(ngDevMode ? [{ debugName: "dropIndicatorState" }] : /* istanbul ignore next */ []));
505
- draggedBlockHighlightState = signal(null, ...(ngDevMode ? [{ debugName: "draggedBlockHighlightState" }] : /* istanbul ignore next */ []));
506
- handle = this.handleState.asReadonly();
507
- dropIndicator = this.dropIndicatorState.asReadonly();
508
- draggedBlockHighlight = this.draggedBlockHighlightState.asReadonly();
509
- surface = null;
510
- currentBlock = null;
511
- pendingBlock = null;
512
- lastPointerClientY = null;
513
- refreshFrame = null;
514
- handleKey = null;
515
- dragSession = null;
516
- constructor(editor) {
517
- this.editor = editor;
518
- }
519
- connect(surface, destroyRef) {
520
- this.surface = surface;
521
- const pointerMove = (event) => this.handlePointerMove(event);
522
- const pointerLeave = (event) => this.handlePointerLeave(event);
523
- const scheduleRefresh = () => this.scheduleRefresh();
524
- surface.addEventListener('pointermove', pointerMove, {
525
- passive: true,
526
- });
527
- surface.addEventListener('mouseleave', pointerLeave);
528
- surface.addEventListener('scroll', scheduleRefresh, {
529
- passive: true,
530
- });
531
- window.addEventListener('scroll', scheduleRefresh, {
532
- passive: true,
533
- });
534
- window.addEventListener('resize', scheduleRefresh, {
535
- passive: true,
536
- });
537
- destroyRef.onDestroy(() => {
538
- this.finishDrag(false);
539
- this.cancelScheduledRefresh();
540
- this.surface = null;
541
- this.currentBlock = null;
542
- this.pendingBlock = null;
543
- surface.removeEventListener('pointermove', pointerMove);
544
- surface.removeEventListener('mouseleave', pointerLeave);
545
- surface.removeEventListener('scroll', scheduleRefresh);
546
- window.removeEventListener('scroll', scheduleRefresh);
547
- window.removeEventListener('resize', scheduleRefresh);
548
- });
549
- }
550
- hide() {
551
- this.currentBlock = null;
552
- this.pendingBlock = null;
553
- this.lastPointerClientY = null;
554
- this.cancelScheduledRefresh();
555
- this.setHandle(null);
556
- }
557
- startDrag(event, handle) {
558
- if (event.button !== 0 || this.dragSession) {
559
- return;
560
- }
561
- const surface = this.surface;
562
- const sourceBlock = surface && findDragHandleBlockByPos(surface, handle.target.pos);
563
- if (!surface || !sourceBlock) {
564
- return;
565
- }
566
- event.preventDefault();
567
- const session = {
568
- handle,
569
- sourceBlock,
570
- originX: event.clientX,
571
- originY: event.clientY,
572
- dragging: false,
573
- previousBodyCursor: document.body.style.cursor,
574
- previousBodyUserSelect: document.body.style.userSelect,
575
- pointerMove: (moveEvent) => this.handleDragPointerMove(moveEvent),
576
- pointerUp: (upEvent) => this.handleDragPointerUp(upEvent),
577
- pointerCancel: () => this.finishDrag(false),
578
- };
579
- this.dragSession = session;
580
- window.addEventListener('pointermove', session.pointerMove, {
581
- passive: false,
582
- });
583
- window.addEventListener('pointerup', session.pointerUp, {
584
- passive: false,
585
- });
586
- window.addEventListener('pointercancel', session.pointerCancel);
587
- }
588
- handlePointerMove(event) {
589
- if (this.dragSession?.dragging) {
590
- return;
591
- }
592
- const surface = this.surface;
593
- const target = event.target;
594
- if (!surface || !(target instanceof Element)) {
595
- return;
596
- }
597
- const block = findDragHandleBlock(target, surface);
598
- if (!block) {
599
- return;
600
- }
601
- if (event instanceof MouseEvent) {
602
- this.lastPointerClientY = event.clientY;
603
- }
604
- this.pendingBlock = block;
605
- this.scheduleRefresh();
606
- }
607
- handlePointerLeave(event) {
608
- if (this.dragSession?.dragging) {
609
- return;
610
- }
611
- const relatedTarget = event instanceof MouseEvent ? event.relatedTarget : null;
612
- if (relatedTarget instanceof Element &&
613
- relatedTarget.closest('[data-qalma-drag-handle]')) {
614
- return;
615
- }
616
- this.hide();
617
- }
618
- handleDragPointerMove(event) {
619
- const session = this.dragSession;
620
- if (!session || !(event instanceof MouseEvent)) {
621
- return;
622
- }
623
- const distance = Math.hypot(event.clientX - session.originX, event.clientY - session.originY);
624
- if (!session.dragging && distance < DRAG_START_DISTANCE) {
625
- return;
626
- }
627
- event.preventDefault();
628
- if (!session.dragging) {
629
- this.activateDragSession(session);
630
- }
631
- this.updateDropIndicator(event.clientY);
632
- }
633
- handleDragPointerUp(event) {
634
- if (this.dragSession?.dragging && event instanceof MouseEvent) {
635
- event.preventDefault();
636
- }
637
- this.finishDrag(true);
638
- }
639
- activateDragSession(session) {
640
- session.dragging = true;
641
- this.surface?.setAttribute('data-qalma-drag-active', 'true');
642
- document.body.style.cursor = 'grabbing';
643
- document.body.style.userSelect = 'none';
644
- this.updateDraggedBlockHighlight(session.sourceBlock);
645
- }
646
- updateDropIndicator(clientY) {
647
- const surface = this.surface;
648
- const session = this.dragSession;
649
- const candidate = surface && session
650
- ? findDragDropCandidate(surface, session.handle.target.pos, clientY)
651
- : null;
652
- if (!candidate ||
653
- !this.editor().canExecute('moveBlockTo', candidate.target)) {
654
- this.setDropIndicator(null);
655
- return;
656
- }
657
- this.setDropIndicator({
658
- target: candidate.target,
659
- transform: `translate3d(${Math.round(candidate.lineX)}px, ${Math.round(candidate.lineY)}px, 0)`,
660
- width: Math.round(candidate.lineWidth),
661
- });
662
- }
663
- updateDraggedBlockHighlight(block) {
664
- const rect = block.getBoundingClientRect();
665
- this.draggedBlockHighlightState.set({
666
- transform: `translate3d(${Math.round(rect.left)}px, ${Math.round(rect.top)}px, 0)`,
667
- width: Math.round(rect.width),
668
- height: Math.round(rect.height),
669
- });
670
- }
671
- finishDrag(shouldDrop) {
672
- const session = this.dragSession;
673
- if (!session) {
674
- return;
675
- }
676
- const dropTarget = this.dropIndicatorState()?.target ?? null;
677
- window.removeEventListener('pointermove', session.pointerMove);
678
- window.removeEventListener('pointerup', session.pointerUp);
679
- window.removeEventListener('pointercancel', session.pointerCancel);
680
- this.surface?.removeAttribute('data-qalma-drag-active');
681
- document.body.style.cursor = session.previousBodyCursor;
682
- document.body.style.userSelect = session.previousBodyUserSelect;
683
- this.dragSession = null;
684
- this.setDropIndicator(null);
685
- this.draggedBlockHighlightState.set(null);
686
- if (shouldDrop && session.dragging && dropTarget) {
687
- this.editor().execute('moveBlockTo', dropTarget);
688
- this.hide();
689
- }
690
- }
691
- scheduleRefresh() {
692
- if (this.refreshFrame !== null) {
693
- return;
694
- }
695
- this.refreshFrame = requestAnimationFrame(() => {
696
- this.refreshFrame = null;
697
- this.refresh();
698
- });
699
- }
700
- refresh() {
701
- const block = this.pendingBlock ?? this.currentBlock;
702
- this.pendingBlock = null;
703
- if (!block) {
704
- this.hide();
705
- return;
706
- }
707
- this.updateFromBlock(block);
708
- }
709
- updateFromBlock(block) {
710
- const surface = this.surface;
711
- const target = parseDragHandleTarget(block);
712
- if (!surface || !target) {
713
- this.hide();
714
- return;
715
- }
716
- const rect = block.getBoundingClientRect();
717
- if (!isRectVisibleInsideSurface$1(rect, surface)) {
718
- this.hide();
719
- return;
720
- }
721
- const surfaceRect = surface.getBoundingClientRect();
722
- const position = anchorToRect(rect, {
723
- placement: 'left',
724
- boundary: surfaceRect,
725
- size: { width: HANDLE_BUTTON_SIZE, height: HANDLE_BUTTON_SIZE },
726
- gap: HANDLE_GAP,
727
- edgeMargin: HANDLE_EDGE_MARGIN,
728
- align: this.lastPointerClientY ?? rect.top + rect.height / 2,
729
- });
730
- const canMoveUp = this.editor().canExecute('moveBlockUp', target);
731
- const canMoveDown = this.editor().canExecute('moveBlockDown', target);
732
- this.currentBlock = block;
733
- this.setHandle({
734
- target,
735
- transform: `translate3d(${Math.round(position.left)}px, ${Math.round(position.top)}px, 0)`,
736
- blockType: block.dataset['qalmaDragHandleType'] ?? 'block',
737
- canMoveUp,
738
- canMoveDown,
739
- });
740
- }
741
- cancelScheduledRefresh() {
742
- if (this.refreshFrame === null) {
743
- return;
744
- }
745
- cancelAnimationFrame(this.refreshFrame);
746
- this.refreshFrame = null;
747
- }
748
- setHandle(handle) {
749
- const nextKey = handle
750
- ? [
751
- handle.target.pos,
752
- handle.transform,
753
- handle.blockType,
754
- handle.canMoveUp,
755
- handle.canMoveDown,
756
- ].join(':')
757
- : null;
758
- if (nextKey === this.handleKey) {
759
- return;
760
- }
761
- this.handleKey = nextKey;
762
- this.handleState.set(handle);
763
- }
764
- setDropIndicator(indicator) {
765
- this.dropIndicatorState.set(indicator);
766
- }
767
- }
768
- function findDragDropCandidate(surface, sourcePos, clientY) {
769
- const blocks = Array.from(surface.querySelectorAll('[data-qalma-drag-handle-block]'));
770
- const lineRect = getDropLineRect(surface);
771
- let lastCandidate = null;
772
- for (const block of blocks) {
773
- const range = parseDragHandleBlockRange(block);
774
- if (!range) {
775
- continue;
776
- }
777
- const rect = block.getBoundingClientRect();
778
- const midpoint = rect.top + rect.height / 2;
779
- if (clientY < midpoint) {
780
- return {
781
- target: {
782
- pos: sourcePos,
783
- targetPos: range.pos,
784
- },
785
- lineX: lineRect.left,
786
- lineY: rect.top,
787
- lineWidth: lineRect.width,
788
- };
789
- }
790
- lastCandidate = {
791
- target: {
792
- pos: sourcePos,
793
- targetPos: range.to,
794
- },
795
- lineX: lineRect.left,
796
- lineY: rect.bottom,
797
- lineWidth: lineRect.width,
798
- };
799
- }
800
- return lastCandidate;
801
- }
802
- function getDropLineRect(surface) {
803
- const content = surface.querySelector('.ProseMirror') ?? surface;
804
- const rect = content.getBoundingClientRect();
805
- const width = Math.max(0, rect.width - DROP_LINE_EDGE_MARGIN * 2);
806
- return {
807
- left: rect.left + DROP_LINE_EDGE_MARGIN,
808
- width,
809
- };
810
- }
811
- function findDragHandleBlock(target, surface) {
812
- const block = target.closest('[data-qalma-drag-handle-block]');
813
- return block && surface.contains(block) ? block : null;
814
- }
815
- function findDragHandleBlockByPos(surface, pos) {
816
- const blocks = Array.from(surface.querySelectorAll('[data-qalma-drag-handle-block]'));
817
- return (blocks.find((block) => parseDragHandleTarget(block)?.pos === pos) ?? null);
818
- }
819
- function parseDragHandleTarget(block) {
820
- const pos = Number(block.dataset['qalmaDragHandlePos']);
821
- return Number.isInteger(pos) ? { pos } : null;
822
- }
823
- function parseDragHandleBlockRange(block) {
824
- const target = parseDragHandleTarget(block);
825
- const to = Number(block.dataset['qalmaDragHandleTo']);
826
- return target && Number.isInteger(to) ? { ...target, to } : null;
827
- }
828
- function isRectVisibleInsideSurface$1(rect, surface) {
829
- const surfaceRect = surface.getBoundingClientRect();
830
- return (rect.bottom > surfaceRect.top &&
831
- rect.top < surfaceRect.bottom &&
832
- rect.right > surfaceRect.left &&
833
- rect.left < surfaceRect.right);
834
- }
835
-
836
- class QalmaDragHandleDirective {
837
- editor = input.required({ ...(ngDevMode ? { debugName: "editor" } : /* istanbul ignore next */ {}), alias: 'qalmaDragHandle' });
838
- destroyRef = inject(DestroyRef);
354
+ class QalmaButton {
839
355
  host = inject(ElementRef);
840
- controller = new QalmaDragHandleController(() => this.editor());
841
- handle = this.controller.handle;
842
- dropIndicator = this.controller.dropIndicator;
843
- draggedBlockHighlight = this.controller.draggedBlockHighlight;
844
- constructor() {
845
- afterNextRender(() => {
846
- this.controller.connect(this.host.nativeElement, this.destroyRef);
847
- });
848
- }
849
- hide() {
850
- this.controller.hide();
851
- }
852
- startDrag(event, handle) {
853
- this.controller.startDrag(event, handle);
854
- }
855
- static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.17", ngImport: i0, type: QalmaDragHandleDirective, deps: [], target: i0.ɵɵFactoryTarget.Directive });
856
- static ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "17.1.0", version: "21.2.17", type: QalmaDragHandleDirective, isStandalone: true, selector: "[qalmaDragHandle]", inputs: { editor: { classPropertyName: "editor", publicName: "qalmaDragHandle", isSignal: true, isRequired: true, transformFunction: null } }, exportAs: ["qalmaDragHandle"], ngImport: i0 });
356
+ initialClass = this.host.nativeElement.getAttribute('class') ?? '';
357
+ variant = input('default', ...(ngDevMode ? [{ debugName: "variant" }] : /* istanbul ignore next */ []));
358
+ size = input('default', ...(ngDevMode ? [{ debugName: "size" }] : /* istanbul ignore next */ []));
359
+ computedClass = computed(() => cn(buttonVariants({ variant: this.variant(), size: this.size() }), this.initialClass), ...(ngDevMode ? [{ debugName: "computedClass" }] : /* istanbul ignore next */ []));
360
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.17", ngImport: i0, type: QalmaButton, deps: [], target: i0.ɵɵFactoryTarget.Directive });
361
+ static ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "17.1.0", version: "21.2.17", type: QalmaButton, isStandalone: true, selector: "button[qalmaBtn], a[qalmaBtn]", inputs: { variant: { classPropertyName: "variant", publicName: "variant", isSignal: true, isRequired: false, transformFunction: null }, size: { classPropertyName: "size", publicName: "size", isSignal: true, isRequired: false, transformFunction: null } }, host: { properties: { "class": "computedClass()" } }, ngImport: i0 });
857
362
  }
858
- i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.17", ngImport: i0, type: QalmaDragHandleDirective, decorators: [{
363
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.17", ngImport: i0, type: QalmaButton, decorators: [{
859
364
  type: Directive,
860
365
  args: [{
861
- exportAs: 'qalmaDragHandle',
862
- selector: '[qalmaDragHandle]',
366
+ selector: 'button[qalmaBtn], a[qalmaBtn]',
367
+ host: {
368
+ '[class]': 'computedClass()',
369
+ },
863
370
  }]
864
- }], ctorParameters: () => [], propDecorators: { editor: [{ type: i0.Input, args: [{ isSignal: true, alias: "qalmaDragHandle", required: true }] }] } });
371
+ }], propDecorators: { variant: [{ type: i0.Input, args: [{ isSignal: true, alias: "variant", required: false }] }], size: [{ type: i0.Input, args: [{ isSignal: true, alias: "size", required: false }] }] } });
865
372
 
866
373
  class QalmaDragHandle {
867
374
  editor = input.required(...(ngDevMode ? [{ debugName: "editor" }] : /* istanbul ignore next */ []));
@@ -1170,108 +677,6 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.17", ngImpo
1170
677
  }]
1171
678
  }], ctorParameters: () => [], propDecorators: { editor: [{ type: i0.Input, args: [{ isSignal: true, alias: "editor", required: true }] }], handle: [{ type: i0.Input, args: [{ isSignal: true, alias: "handle", required: false }] }], dropIndicator: [{ type: i0.Input, args: [{ isSignal: true, alias: "dropIndicator", required: false }] }], draggedBlockHighlight: [{ type: i0.Input, args: [{ isSignal: true, alias: "draggedBlockHighlight", required: false }] }], dismiss: [{ type: i0.Output, args: ["dismiss"] }], dragStart: [{ type: i0.Output, args: ["dragStart"] }] } });
1172
679
 
1173
- const DEFAULT_MARGIN = 12;
1174
- const DEFAULT_GAP = 8;
1175
- /**
1176
- * Positions a floating list under an anchor rect, flipping it above when there
1177
- * is not enough room below, and clamping its height and horizontal position to
1178
- * the viewport. Shared by the mention and slash-command menus, whose flip
1179
- * behavior `anchorToRect` does not cover (it has no flip / dynamic max-height).
1180
- */
1181
- function flipAbovePlacement(rect, options) {
1182
- const { width, desiredHeight, minHeight, margin = DEFAULT_MARGIN, gap = DEFAULT_GAP, } = options;
1183
- const availableBelow = window.innerHeight - rect.bottom - margin - gap;
1184
- const availableAbove = rect.top - margin - gap;
1185
- const openAbove = availableBelow < desiredHeight && availableAbove > availableBelow;
1186
- const availableHeight = Math.max(minHeight, openAbove ? availableAbove : availableBelow);
1187
- const maxHeight = Math.min(desiredHeight, availableHeight);
1188
- const leftBoundary = Math.max(margin, window.innerWidth - width - margin);
1189
- return {
1190
- left: Math.min(Math.max(rect.left, margin), leftBoundary),
1191
- top: openAbove ? null : rect.bottom + gap,
1192
- bottom: openAbove ? window.innerHeight - rect.top + gap : null,
1193
- maxHeight,
1194
- };
1195
- }
1196
-
1197
- /** Option buttons carry this attribute so the base can focus/scroll to one by index. */
1198
- const SUGGESTION_OPTION_INDEX_ATTR = 'data-suggestion-index';
1199
- /** The scrollable options container carries this attribute (optional). */
1200
- const SUGGESTION_OPTIONS_SCROLLER_ATTR = 'data-suggestion-options';
1201
- /**
1202
- * Shared behavior for caret-anchored suggestion menus (mention, slash command):
1203
- * flip-above placement input, active-option highlighting, and in-menu keyboard
1204
- * handling (Escape dismisses, Arrow moves + focuses, Enter/Space picks) with
1205
- * wrap-around and optional scroll-into-view. Concrete menus only supply their
1206
- * own item markup and expose their option list via `optionList`.
1207
- */
1208
- class QalmaSuggestionMenu {
1209
- elementRef = inject(ElementRef);
1210
- placement = input(null, ...(ngDevMode ? [{ debugName: "placement" }] : /* istanbul ignore next */ []));
1211
- activeIndex = input(0, ...(ngDevMode ? [{ debugName: "activeIndex" }] : /* istanbul ignore next */ []));
1212
- activate = output();
1213
- pick = output();
1214
- dismiss = output();
1215
- preserveSelection(event) {
1216
- event.preventDefault();
1217
- }
1218
- handleOptionKeydown(event, option, index) {
1219
- if (event.key === 'Escape') {
1220
- event.preventDefault();
1221
- this.dismiss.emit();
1222
- return;
1223
- }
1224
- if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
1225
- event.preventDefault();
1226
- const nextIndex = this.getNextIndex(index, event.key === 'ArrowDown' ? 1 : -1);
1227
- this.activate.emit(nextIndex);
1228
- queueMicrotask(() => this.focusOption(nextIndex));
1229
- return;
1230
- }
1231
- if (event.key === 'Enter' || event.key === ' ' || event.key === 'Spacebar') {
1232
- event.preventDefault();
1233
- this.pick.emit(option);
1234
- }
1235
- }
1236
- focusOption(index) {
1237
- this.optionElement(index)?.focus();
1238
- // No-op for menus without a marked scroller (native focus handles scroll).
1239
- this.scrollOptionIntoView(index);
1240
- }
1241
- scrollOptionIntoView(index) {
1242
- const scroller = this.optionsScroller();
1243
- const option = this.optionElement(index);
1244
- if (!scroller || !option) {
1245
- return;
1246
- }
1247
- const optionTop = option.offsetTop - scroller.offsetTop;
1248
- const optionBottom = optionTop + option.offsetHeight;
1249
- const visibleTop = scroller.scrollTop;
1250
- const visibleBottom = visibleTop + scroller.clientHeight;
1251
- if (optionTop < visibleTop) {
1252
- scroller.scrollTop = optionTop;
1253
- }
1254
- else if (optionBottom > visibleBottom) {
1255
- scroller.scrollTop = optionBottom - scroller.clientHeight;
1256
- }
1257
- }
1258
- getNextIndex(index, delta) {
1259
- const length = this.optionList().length;
1260
- return length > 0 ? (index + delta + length) % length : index;
1261
- }
1262
- optionElement(index) {
1263
- return this.elementRef.nativeElement.querySelector(`[${SUGGESTION_OPTION_INDEX_ATTR}="${index}"]`);
1264
- }
1265
- optionsScroller() {
1266
- return this.elementRef.nativeElement.querySelector(`[${SUGGESTION_OPTIONS_SCROLLER_ATTR}]`);
1267
- }
1268
- static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.17", ngImport: i0, type: QalmaSuggestionMenu, deps: [], target: i0.ɵɵFactoryTarget.Directive });
1269
- static ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "17.1.0", version: "21.2.17", type: QalmaSuggestionMenu, isStandalone: true, inputs: { placement: { classPropertyName: "placement", publicName: "placement", isSignal: true, isRequired: false, transformFunction: null }, activeIndex: { classPropertyName: "activeIndex", publicName: "activeIndex", isSignal: true, isRequired: false, transformFunction: null } }, outputs: { activate: "activate", pick: "pick", dismiss: "dismiss" }, ngImport: i0 });
1270
- }
1271
- i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.17", ngImport: i0, type: QalmaSuggestionMenu, decorators: [{
1272
- type: Directive
1273
- }], propDecorators: { placement: [{ type: i0.Input, args: [{ isSignal: true, alias: "placement", required: false }] }], activeIndex: [{ type: i0.Input, args: [{ isSignal: true, alias: "activeIndex", required: false }] }], activate: [{ type: i0.Output, args: ["activate"] }], pick: [{ type: i0.Output, args: ["pick"] }], dismiss: [{ type: i0.Output, args: ["dismiss"] }] } });
1274
-
1275
680
  /**
1276
681
  * Caret-anchored mention suggestion menu: an avatar-initial list with a loading
1277
682
  * state. Positioning, keyboard handling and active highlighting come from
@@ -1666,148 +1071,6 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.17", ngImpo
1666
1071
  }]
1667
1072
  }], ctorParameters: () => [], propDecorators: { options: [{ type: i0.Input, args: [{ isSignal: true, alias: "options", required: false }] }] } });
1668
1073
 
1669
- const LINK_POPOVER_WIDTH = 360;
1670
- // Both the editing and preview rows are a single h-9 (36px) control plus
1671
- // padding/border; this is an estimate since the actual element isn't
1672
- // rendered yet when the placement is computed.
1673
- const LINK_POPOVER_HEIGHT_ESTIMATE = 56;
1674
- function createLinkPopoverPlacement(element) {
1675
- const rect = element.getBoundingClientRect();
1676
- return anchorToRect(rect, {
1677
- placement: 'bottom',
1678
- boundary: {
1679
- top: 0,
1680
- bottom: window.innerHeight,
1681
- left: 0,
1682
- right: window.innerWidth,
1683
- },
1684
- size: { width: LINK_POPOVER_WIDTH, height: LINK_POPOVER_HEIGHT_ESTIMATE },
1685
- gap: 8,
1686
- edgeMargin: 16,
1687
- align: 'start',
1688
- });
1689
- }
1690
- function findEditorLinkElement(target) {
1691
- if (!(target instanceof Element)) {
1692
- return null;
1693
- }
1694
- const element = target.closest('.ProseMirror a[href]');
1695
- return element instanceof HTMLAnchorElement ? element : null;
1696
- }
1697
-
1698
- class LinkPopoverController {
1699
- editor;
1700
- popover = signal(null, ...(ngDevMode ? [{ debugName: "popover" }] : /* istanbul ignore next */ []));
1701
- href = signal('', ...(ngDevMode ? [{ debugName: "href" }] : /* istanbul ignore next */ []));
1702
- hideTimeout;
1703
- constructor(editor) {
1704
- this.editor = editor;
1705
- }
1706
- showToolbarEditor(event) {
1707
- const linkState = this.editor.query('link');
1708
- const target = event.currentTarget;
1709
- if (!(target instanceof HTMLElement)) {
1710
- return;
1711
- }
1712
- this.keepOpen();
1713
- this.href.set(linkState?.href ?? 'https://');
1714
- this.popover.set({
1715
- ...createLinkPopoverPlacement(target),
1716
- editing: true,
1717
- element: null,
1718
- href: linkState?.href ?? '',
1719
- rel: linkState?.rel ?? null,
1720
- target: linkState?.target ?? null,
1721
- text: linkState?.text ?? '',
1722
- });
1723
- }
1724
- showPreview(event) {
1725
- const element = findEditorLinkElement(event.target);
1726
- if (!element) {
1727
- return;
1728
- }
1729
- this.keepOpen();
1730
- this.popover.set({
1731
- ...createLinkPopoverPlacement(element),
1732
- editing: false,
1733
- element,
1734
- href: element.href,
1735
- rel: element.rel || null,
1736
- target: element.target === '_blank' ? '_blank' : null,
1737
- text: element.textContent?.trim() ?? '',
1738
- });
1739
- }
1740
- scheduleHideFromEvent(event) {
1741
- const nextTarget = event.relatedTarget;
1742
- if (nextTarget instanceof Element &&
1743
- (findEditorLinkElement(nextTarget) ||
1744
- nextTarget.closest('[data-qalma-link-popover]'))) {
1745
- return;
1746
- }
1747
- if (!this.popover()?.editing) {
1748
- this.scheduleHide();
1749
- }
1750
- }
1751
- edit(popover) {
1752
- this.keepOpen();
1753
- this.selectLink(popover);
1754
- this.href.set(popover.href);
1755
- this.popover.set({
1756
- ...popover,
1757
- editing: true,
1758
- });
1759
- }
1760
- save(popover) {
1761
- const href = this.href().trim();
1762
- if (!href) {
1763
- return;
1764
- }
1765
- this.selectLink(popover);
1766
- this.editor.execute('setLink', href);
1767
- this.hide();
1768
- }
1769
- remove(popover) {
1770
- this.selectLink(popover);
1771
- this.editor.execute('unsetLink');
1772
- this.hide();
1773
- }
1774
- hide() {
1775
- this.clearHideTimeout();
1776
- this.popover.set(null);
1777
- }
1778
- keepOpen() {
1779
- this.clearHideTimeout();
1780
- }
1781
- scheduleHide() {
1782
- this.clearHideTimeout();
1783
- if (this.popover()?.editing) {
1784
- return;
1785
- }
1786
- this.hideTimeout = setTimeout(() => {
1787
- if (this.popover()?.editing) {
1788
- this.hideTimeout = undefined;
1789
- return;
1790
- }
1791
- this.popover.set(null);
1792
- this.hideTimeout = undefined;
1793
- }, 160);
1794
- }
1795
- selectLink(popover) {
1796
- if (popover.element) {
1797
- this.editor.execute('selectLink', {
1798
- element: popover.element,
1799
- });
1800
- }
1801
- }
1802
- clearHideTimeout() {
1803
- if (!this.hideTimeout) {
1804
- return;
1805
- }
1806
- clearTimeout(this.hideTimeout);
1807
- this.hideTimeout = undefined;
1808
- }
1809
- }
1810
-
1811
1074
  class QalmaLinkPopover {
1812
1075
  popover = input(null, ...(ngDevMode ? [{ debugName: "popover" }] : /* istanbul ignore next */ []));
1813
1076
  href = input.required(...(ngDevMode ? [{ debugName: "href" }] : /* istanbul ignore next */ []));
@@ -2056,13 +1319,34 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.17", ngImpo
2056
1319
  }]
2057
1320
  }], propDecorators: { popover: [{ type: i0.Input, args: [{ isSignal: true, alias: "popover", required: false }] }], href: [{ type: i0.Input, args: [{ isSignal: true, alias: "href", required: true }] }], hrefChange: [{ type: i0.Output, args: ["hrefChange"] }], edit: [{ type: i0.Output, args: ["edit"] }], save: [{ type: i0.Output, args: ["save"] }], remove: [{ type: i0.Output, args: ["remove"] }], dismiss: [{ type: i0.Output, args: ["dismiss"] }], keepOpen: [{ type: i0.Output, args: ["keepOpen"] }], scheduleHide: [{ type: i0.Output, args: ["scheduleHide"] }] } });
2058
1321
 
1322
+ /**
1323
+ * Floating, selection-anchored toolbar container. It owns the hard parts — the
1324
+ * fixed-position `transform` from a {@link QalmaContextualToolbarPlacement}
1325
+ * (see `QalmaSelectionToolbarDirective`), preserving the editor selection on
1326
+ * pointer-down, and Escape-to-dismiss — and projects whatever controls you put
1327
+ * inside it. Nothing about the button set is baked in: compose
1328
+ * `<qalma-toolbar-button>` for commands and your own buttons for app actions
1329
+ * (opening a link editor, a color picker, an AI action…).
1330
+ *
1331
+ * The container's `mousedown` handler preserves the selection for every
1332
+ * projected control via event bubbling, so inner buttons don't each need to.
1333
+ *
1334
+ * ```html
1335
+ * <qalma-contextual-toolbar
1336
+ * [placement]="selection.placement()"
1337
+ * (dismiss)="selection.hide()"
1338
+ * >
1339
+ * <qalma-toolbar-button command="toggleBold" icon="lucideBold" label="Bold" />
1340
+ * <qalma-toolbar-button command="toggleItalic" icon="lucideItalic" label="Italic" />
1341
+ * <button type="button" [class]="TOOLBAR_BUTTON_CLASS" (click)="openLinkEditor()">…</button>
1342
+ * </qalma-contextual-toolbar>
1343
+ * ```
1344
+ */
2059
1345
  class QalmaContextualToolbar {
2060
- editor = input.required(...(ngDevMode ? [{ debugName: "editor" }] : /* istanbul ignore next */ []));
2061
1346
  placement = input(null, ...(ngDevMode ? [{ debugName: "placement" }] : /* istanbul ignore next */ []));
2062
- requestLink = output();
1347
+ /** Accessible name for the toolbar region. */
1348
+ label = input('Selection formatting', ...(ngDevMode ? [{ debugName: "label" }] : /* istanbul ignore next */ []));
2063
1349
  dismiss = output();
2064
- canSetLink = computed(() => this.editor().canExecute('setLink', 'https://angular.dev'), ...(ngDevMode ? [{ debugName: "canSetLink" }] : /* istanbul ignore next */ []));
2065
- linkActive = computed(() => this.editor().isCommandActive('setLink'), ...(ngDevMode ? [{ debugName: "linkActive" }] : /* istanbul ignore next */ []));
2066
1350
  preserveSelection(event) {
2067
1351
  event.preventDefault();
2068
1352
  }
@@ -2071,397 +1355,57 @@ class QalmaContextualToolbar {
2071
1355
  this.dismiss.emit();
2072
1356
  }
2073
1357
  static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.17", ngImport: i0, type: QalmaContextualToolbar, deps: [], target: i0.ɵɵFactoryTarget.Component });
2074
- static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.2.17", type: QalmaContextualToolbar, isStandalone: true, selector: "qalma-contextual-toolbar", inputs: { editor: { classPropertyName: "editor", publicName: "editor", isSignal: true, isRequired: true, transformFunction: null }, placement: { classPropertyName: "placement", publicName: "placement", isSignal: true, isRequired: false, transformFunction: null } }, outputs: { requestLink: "requestLink", dismiss: "dismiss" }, providers: [
2075
- provideIcons({
2076
- lucideBold,
2077
- lucideCode,
2078
- lucideItalic,
2079
- lucideLetterText,
2080
- lucideLink,
2081
- }),
2082
- ], ngImport: i0, template: `
1358
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.2.17", type: QalmaContextualToolbar, isStandalone: true, selector: "qalma-contextual-toolbar", inputs: { placement: { classPropertyName: "placement", publicName: "placement", isSignal: true, isRequired: false, transformFunction: null }, label: { classPropertyName: "label", publicName: "label", isSignal: true, isRequired: false, transformFunction: null } }, outputs: { dismiss: "dismiss" }, ngImport: i0, template: `
2083
1359
  @if (placement(); as placement) {
2084
1360
  <qalma-toolbar
2085
- label="Selection formatting"
1361
+ [label]="label()"
2086
1362
  class="fixed left-0 top-0 z-30 flex items-center gap-0.5 rounded-md border border-border bg-popover p-1 text-popover-foreground shadow-lg will-change-transform"
2087
1363
  [style.transform]="placement.transform"
2088
1364
  (mousedown)="preserveSelection($event)"
2089
1365
  (keydown.escape)="dismissFromKeyboard($event)"
2090
1366
  >
2091
- <button
2092
- qalmaBtn
2093
- variant="ghost"
2094
- size="icon"
2095
- type="button"
2096
- class="!h-[1.85rem] !w-[1.85rem] cursor-pointer rounded-[0.4rem] disabled:cursor-not-allowed [&.qalma-command-active]:bg-accent-subtle [&.qalma-command-active]:text-accent"
2097
- qalmaCommand="toggleBold"
2098
- title="Bold"
2099
- aria-label="Bold"
2100
- >
2101
- <ng-icon
2102
- class="text-[0.9rem]"
2103
- name="lucideBold"
2104
- aria-hidden="true"
2105
- />
2106
- </button>
2107
- <button
2108
- qalmaBtn
2109
- variant="ghost"
2110
- size="icon"
2111
- type="button"
2112
- class="!h-[1.85rem] !w-[1.85rem] cursor-pointer rounded-[0.4rem] disabled:cursor-not-allowed [&.qalma-command-active]:bg-accent-subtle [&.qalma-command-active]:text-accent"
2113
- qalmaCommand="toggleItalic"
2114
- title="Italic"
2115
- aria-label="Italic"
2116
- >
2117
- <ng-icon
2118
- class="text-[0.9rem]"
2119
- name="lucideItalic"
2120
- aria-hidden="true"
2121
- />
2122
- </button>
2123
- <button
2124
- qalmaBtn
2125
- variant="ghost"
2126
- size="icon"
2127
- type="button"
2128
- class="!h-[1.85rem] !w-[1.85rem] cursor-pointer rounded-[0.4rem] disabled:cursor-not-allowed [&.qalma-command-active]:bg-accent-subtle [&.qalma-command-active]:text-accent"
2129
- qalmaCommand="toggleInlineCode"
2130
- title="Inline code"
2131
- aria-label="Inline code"
2132
- >
2133
- <ng-icon
2134
- class="text-[0.9rem]"
2135
- name="lucideCode"
2136
- aria-hidden="true"
2137
- />
2138
- </button>
2139
- <button
2140
- qalmaBtn
2141
- variant="ghost"
2142
- size="icon"
2143
- type="button"
2144
- class="!h-[1.85rem] !w-[1.85rem] cursor-pointer rounded-[0.4rem] disabled:cursor-not-allowed [&.qalma-command-active]:bg-accent-subtle [&.qalma-command-active]:text-accent"
2145
- qalmaCommand="toggleMonospace"
2146
- title="Monospace"
2147
- aria-label="Monospace"
2148
- >
2149
- <ng-icon
2150
- class="text-[0.9rem]"
2151
- name="lucideLetterText"
2152
- aria-hidden="true"
2153
- />
2154
- </button>
2155
- <span class="mx-0.5 h-5 w-px bg-border" aria-hidden="true"></span>
2156
- <button
2157
- qalmaBtn
2158
- variant="ghost"
2159
- size="icon"
2160
- type="button"
2161
- class="!h-[1.85rem] !w-[1.85rem] cursor-pointer rounded-[0.4rem] disabled:cursor-not-allowed [&.qalma-command-active]:bg-accent-subtle [&.qalma-command-active]:text-accent"
2162
- [class.qalma-command-active]="linkActive()"
2163
- [attr.aria-pressed]="linkActive()"
2164
- [disabled]="!canSetLink()"
2165
- (mousedown)="preserveSelection($event)"
2166
- (click)="requestLink.emit($event)"
2167
- title="Link"
2168
- aria-label="Link"
2169
- >
2170
- <ng-icon
2171
- class="text-[0.9rem]"
2172
- name="lucideLink"
2173
- aria-hidden="true"
2174
- />
2175
- </button>
1367
+ <ng-content />
2176
1368
  </qalma-toolbar>
2177
1369
  }
2178
- `, isInline: true, dependencies: [{ kind: "component", type: NgIcon, selector: "ng-icon", inputs: ["name", "svg", "size", "strokeWidth", "color"] }, { kind: "directive", type: QalmaButton, selector: "button[qalmaBtn], a[qalmaBtn]", inputs: ["variant", "size"] }, { kind: "directive", type: QalmaCommand, selector: "button[qalmaCommand]", inputs: ["qalmaCommand", "qalmaCommandValue"] }, { kind: "component", type: QalmaToolbar, selector: "qalma-toolbar", inputs: ["label"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
1370
+ `, isInline: true, dependencies: [{ kind: "component", type: QalmaToolbar, selector: "qalma-toolbar", inputs: ["label"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
2179
1371
  }
2180
1372
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.17", ngImport: i0, type: QalmaContextualToolbar, decorators: [{
2181
1373
  type: Component,
2182
1374
  args: [{
2183
1375
  changeDetection: ChangeDetectionStrategy.OnPush,
2184
- imports: [NgIcon, QalmaButton, QalmaCommand, QalmaToolbar],
2185
- providers: [
2186
- provideIcons({
2187
- lucideBold,
2188
- lucideCode,
2189
- lucideItalic,
2190
- lucideLetterText,
2191
- lucideLink,
2192
- }),
2193
- ],
1376
+ imports: [QalmaToolbar],
2194
1377
  selector: 'qalma-contextual-toolbar',
2195
1378
  template: `
2196
1379
  @if (placement(); as placement) {
2197
1380
  <qalma-toolbar
2198
- label="Selection formatting"
1381
+ [label]="label()"
2199
1382
  class="fixed left-0 top-0 z-30 flex items-center gap-0.5 rounded-md border border-border bg-popover p-1 text-popover-foreground shadow-lg will-change-transform"
2200
1383
  [style.transform]="placement.transform"
2201
1384
  (mousedown)="preserveSelection($event)"
2202
1385
  (keydown.escape)="dismissFromKeyboard($event)"
2203
1386
  >
2204
- <button
2205
- qalmaBtn
2206
- variant="ghost"
2207
- size="icon"
2208
- type="button"
2209
- class="!h-[1.85rem] !w-[1.85rem] cursor-pointer rounded-[0.4rem] disabled:cursor-not-allowed [&.qalma-command-active]:bg-accent-subtle [&.qalma-command-active]:text-accent"
2210
- qalmaCommand="toggleBold"
2211
- title="Bold"
2212
- aria-label="Bold"
2213
- >
2214
- <ng-icon
2215
- class="text-[0.9rem]"
2216
- name="lucideBold"
2217
- aria-hidden="true"
2218
- />
2219
- </button>
2220
- <button
2221
- qalmaBtn
2222
- variant="ghost"
2223
- size="icon"
2224
- type="button"
2225
- class="!h-[1.85rem] !w-[1.85rem] cursor-pointer rounded-[0.4rem] disabled:cursor-not-allowed [&.qalma-command-active]:bg-accent-subtle [&.qalma-command-active]:text-accent"
2226
- qalmaCommand="toggleItalic"
2227
- title="Italic"
2228
- aria-label="Italic"
2229
- >
2230
- <ng-icon
2231
- class="text-[0.9rem]"
2232
- name="lucideItalic"
2233
- aria-hidden="true"
2234
- />
2235
- </button>
2236
- <button
2237
- qalmaBtn
2238
- variant="ghost"
2239
- size="icon"
2240
- type="button"
2241
- class="!h-[1.85rem] !w-[1.85rem] cursor-pointer rounded-[0.4rem] disabled:cursor-not-allowed [&.qalma-command-active]:bg-accent-subtle [&.qalma-command-active]:text-accent"
2242
- qalmaCommand="toggleInlineCode"
2243
- title="Inline code"
2244
- aria-label="Inline code"
2245
- >
2246
- <ng-icon
2247
- class="text-[0.9rem]"
2248
- name="lucideCode"
2249
- aria-hidden="true"
2250
- />
2251
- </button>
2252
- <button
2253
- qalmaBtn
2254
- variant="ghost"
2255
- size="icon"
2256
- type="button"
2257
- class="!h-[1.85rem] !w-[1.85rem] cursor-pointer rounded-[0.4rem] disabled:cursor-not-allowed [&.qalma-command-active]:bg-accent-subtle [&.qalma-command-active]:text-accent"
2258
- qalmaCommand="toggleMonospace"
2259
- title="Monospace"
2260
- aria-label="Monospace"
2261
- >
2262
- <ng-icon
2263
- class="text-[0.9rem]"
2264
- name="lucideLetterText"
2265
- aria-hidden="true"
2266
- />
2267
- </button>
2268
- <span class="mx-0.5 h-5 w-px bg-border" aria-hidden="true"></span>
2269
- <button
2270
- qalmaBtn
2271
- variant="ghost"
2272
- size="icon"
2273
- type="button"
2274
- class="!h-[1.85rem] !w-[1.85rem] cursor-pointer rounded-[0.4rem] disabled:cursor-not-allowed [&.qalma-command-active]:bg-accent-subtle [&.qalma-command-active]:text-accent"
2275
- [class.qalma-command-active]="linkActive()"
2276
- [attr.aria-pressed]="linkActive()"
2277
- [disabled]="!canSetLink()"
2278
- (mousedown)="preserveSelection($event)"
2279
- (click)="requestLink.emit($event)"
2280
- title="Link"
2281
- aria-label="Link"
2282
- >
2283
- <ng-icon
2284
- class="text-[0.9rem]"
2285
- name="lucideLink"
2286
- aria-hidden="true"
2287
- />
2288
- </button>
1387
+ <ng-content />
2289
1388
  </qalma-toolbar>
2290
1389
  }
2291
1390
  `,
2292
1391
  }]
2293
- }], propDecorators: { editor: [{ type: i0.Input, args: [{ isSignal: true, alias: "editor", required: true }] }], placement: [{ type: i0.Input, args: [{ isSignal: true, alias: "placement", required: false }] }], requestLink: [{ type: i0.Output, args: ["requestLink"] }], dismiss: [{ type: i0.Output, args: ["dismiss"] }] } });
2294
-
2295
- const TOOLBAR_EDGE_MARGIN = 16;
2296
- const TOOLBAR_GAP = 8;
2297
- class QalmaSelectionToolbarController {
2298
- editor;
2299
- placementState = signal(null, ...(ngDevMode ? [{ debugName: "placementState" }] : /* istanbul ignore next */ []));
2300
- placement = this.placementState.asReadonly();
2301
- surface = null;
2302
- refreshFrame = null;
2303
- placementKey = null;
2304
- constructor(editor) {
2305
- this.editor = editor;
2306
- }
2307
- connect(surface, destroyRef) {
2308
- this.surface = surface;
2309
- const refresh = () => this.refresh();
2310
- const scheduleRefresh = () => this.scheduleRefresh();
2311
- const handleKeydown = (event) => this.handleKeydown(event);
2312
- surface.addEventListener('qalma-selection-update', refresh);
2313
- surface.addEventListener('keydown', handleKeydown);
2314
- surface.addEventListener('keyup', refresh);
2315
- surface.addEventListener('mouseup', refresh);
2316
- surface.addEventListener('scroll', scheduleRefresh, {
2317
- passive: true,
2318
- });
2319
- window.addEventListener('scroll', scheduleRefresh, {
2320
- passive: true,
2321
- });
2322
- window.addEventListener('resize', scheduleRefresh, {
2323
- passive: true,
2324
- });
2325
- destroyRef.onDestroy(() => {
2326
- this.cancelScheduledRefresh();
2327
- this.surface = null;
2328
- surface.removeEventListener('qalma-selection-update', refresh);
2329
- surface.removeEventListener('keydown', handleKeydown);
2330
- surface.removeEventListener('keyup', refresh);
2331
- surface.removeEventListener('mouseup', refresh);
2332
- surface.removeEventListener('scroll', scheduleRefresh);
2333
- window.removeEventListener('scroll', scheduleRefresh);
2334
- window.removeEventListener('resize', scheduleRefresh);
2335
- });
2336
- }
2337
- refresh() {
2338
- const surface = this.surface;
2339
- const selection = this.editor().query('selection');
2340
- if (!surface ||
2341
- !selection ||
2342
- selection.empty ||
2343
- selection.text.trim().length === 0) {
2344
- this.hide();
2345
- return;
2346
- }
2347
- const domSelection = window.getSelection();
2348
- if (!domSelection ||
2349
- domSelection.rangeCount === 0 ||
2350
- !isSelectionInsideSurface(domSelection, surface)) {
2351
- this.hide();
2352
- return;
2353
- }
2354
- const rect = getVisibleSelectionRect(domSelection.getRangeAt(0));
2355
- if (!rect || !isRectVisibleInsideSurface(rect, surface)) {
2356
- this.hide();
2357
- return;
2358
- }
2359
- const viewportWidth = document.documentElement.clientWidth || window.innerWidth;
2360
- const x = Math.round(clamp(rect.left + rect.width / 2, TOOLBAR_EDGE_MARGIN, viewportWidth - TOOLBAR_EDGE_MARGIN));
2361
- const y = Math.round(Math.max(TOOLBAR_EDGE_MARGIN, rect.top - TOOLBAR_GAP));
2362
- this.setPlacement({
2363
- transform: `translate3d(${x}px, ${y}px, 0) translate(-50%, -100%)`,
2364
- });
2365
- }
2366
- hide() {
2367
- this.setPlacement(null);
2368
- }
2369
- handleKeydown(event) {
2370
- if (!(event instanceof KeyboardEvent) ||
2371
- event.key !== 'Escape' ||
2372
- !this.placement()) {
2373
- return;
2374
- }
2375
- event.preventDefault();
2376
- this.hide();
2377
- }
2378
- scheduleRefresh() {
2379
- if (!this.placement() || this.refreshFrame !== null) {
2380
- return;
2381
- }
2382
- this.refreshFrame = requestAnimationFrame(() => {
2383
- this.refreshFrame = null;
2384
- this.refresh();
2385
- });
2386
- }
2387
- cancelScheduledRefresh() {
2388
- if (this.refreshFrame === null) {
2389
- return;
2390
- }
2391
- cancelAnimationFrame(this.refreshFrame);
2392
- this.refreshFrame = null;
2393
- }
2394
- setPlacement(placement) {
2395
- const nextKey = placement?.transform ?? null;
2396
- if (nextKey === this.placementKey) {
2397
- return;
2398
- }
2399
- this.placementKey = nextKey;
2400
- this.placementState.set(placement);
2401
- }
2402
- }
2403
- function getVisibleSelectionRect(range) {
2404
- const rect = range.getBoundingClientRect();
2405
- if (rect.width > 0 || rect.height > 0) {
2406
- return rect;
2407
- }
2408
- for (const clientRect of Array.from(range.getClientRects())) {
2409
- if (clientRect.width > 0 || clientRect.height > 0) {
2410
- return clientRect;
2411
- }
2412
- }
2413
- return null;
2414
- }
2415
- function isSelectionInsideSurface(selection, surface) {
2416
- return (isNodeInsideSurface(selection.anchorNode, surface) &&
2417
- isNodeInsideSurface(selection.focusNode, surface));
2418
- }
2419
- function isNodeInsideSurface(node, surface) {
2420
- const element = node instanceof Element ? node : node?.parentElement;
2421
- return Boolean(element && surface.contains(element));
2422
- }
2423
- function isRectVisibleInsideSurface(rect, surface) {
2424
- const surfaceRect = surface.getBoundingClientRect();
2425
- return (rect.bottom > surfaceRect.top &&
2426
- rect.top < surfaceRect.bottom &&
2427
- rect.right > surfaceRect.left &&
2428
- rect.left < surfaceRect.right);
2429
- }
2430
- function clamp(value, min, max) {
2431
- return Math.min(Math.max(value, min), max);
2432
- }
1392
+ }], propDecorators: { placement: [{ type: i0.Input, args: [{ isSignal: true, alias: "placement", required: false }] }], label: [{ type: i0.Input, args: [{ isSignal: true, alias: "label", required: false }] }], dismiss: [{ type: i0.Output, args: ["dismiss"] }] } });
2433
1393
 
2434
- class QalmaSelectionToolbarDirective {
2435
- editor = input.required({ ...(ngDevMode ? { debugName: "editor" } : /* istanbul ignore next */ {}), alias: 'qalmaSelectionToolbar' });
2436
- destroyRef = inject(DestroyRef);
2437
- host = inject(ElementRef);
2438
- controller = new QalmaSelectionToolbarController(() => this.editor());
2439
- placement = this.controller.placement;
2440
- constructor() {
2441
- afterNextRender(() => {
2442
- this.controller.connect(this.host.nativeElement, this.destroyRef);
2443
- });
2444
- }
2445
- refresh() {
2446
- this.controller.refresh();
2447
- }
2448
- hide() {
2449
- this.controller.hide();
2450
- }
2451
- static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.17", ngImport: i0, type: QalmaSelectionToolbarDirective, deps: [], target: i0.ɵɵFactoryTarget.Directive });
2452
- static ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "17.1.0", version: "21.2.17", type: QalmaSelectionToolbarDirective, isStandalone: true, selector: "[qalmaSelectionToolbar]", inputs: { editor: { classPropertyName: "editor", publicName: "qalmaSelectionToolbar", isSignal: true, isRequired: true, transformFunction: null } }, exportAs: ["qalmaSelectionToolbar"], ngImport: i0 });
2453
- }
2454
- i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.17", ngImport: i0, type: QalmaSelectionToolbarDirective, decorators: [{
2455
- type: Directive,
2456
- args: [{
2457
- exportAs: 'qalmaSelectionToolbar',
2458
- selector: '[qalmaSelectionToolbar]',
2459
- }]
2460
- }], ctorParameters: () => [], propDecorators: { editor: [{ type: i0.Input, args: [{ isSignal: true, alias: "qalmaSelectionToolbar", required: true }] }] } });
1394
+ // @qalma/kit — Tailwind/shadcn-flavored UI for the Qalma editor.
1395
+ //
1396
+ // This entry point is the styled tier: components that pull `cva` + `@ng-icons`
1397
+ // and read the host's design tokens. It re-exports the entire dependency-free
1398
+ // `@qalma/kit/headless` surface (controllers, geometry, keyboard nav) so it
1399
+ // stays a superset — bring-your-own-design-system consumers can import the
1400
+ // leaner `@qalma/kit/headless` directly instead.
1401
+ //
1402
+ // The internal `QalmaButton` directive and `cn()` helper are intentionally NOT
1403
+ // exported: a host already has a button and a class-merge utility in its design
1404
+ // system. They remain private implementation details of the styled components.
2461
1405
 
2462
1406
  /**
2463
1407
  * Generated bundle index. Do not edit.
2464
1408
  */
2465
1409
 
2466
- export { DismissibleOverlay, KeyboardNavigableList, LinkPopoverController, QALMA_TOOLBAR_ALIGN, QALMA_TOOLBAR_CLEAR_FORMATTING, QALMA_TOOLBAR_HEADINGS, QALMA_TOOLBAR_HISTORY, QALMA_TOOLBAR_INLINE_MARKS, QALMA_TOOLBAR_LISTS, QALMA_TOOLBAR_TABLE_INSERT, QALMA_TOOLBAR_TABLE_OPS, QALMA_TOOLBAR_UNSET_LINK, QalmaButton, QalmaContextualToolbar, QalmaDragHandle, QalmaDragHandleController, QalmaDragHandleDirective, QalmaLinkPopover, QalmaMentionMenu, QalmaSelectionToolbarController, QalmaSelectionToolbarDirective, QalmaSlashCommandMenu, QalmaSuggestionMenu, QalmaToolbarButton, QalmaToolbarRegistry, SUGGESTION_OPTIONS_SCROLLER_ATTR, SUGGESTION_OPTION_INDEX_ATTR, TOOLBAR_BUTTON_CLASS, TOOLBAR_SEPARATOR_CLASS, anchorToRect, buttonVariants, cn, createLinkPopoverPlacement, findEditorLinkElement, flipAbovePlacement, isToolbarCommandItem, provideQalmaToolbarIcons };
1410
+ export { QALMA_TOOLBAR_ALIGN, QALMA_TOOLBAR_CLEAR_FORMATTING, QALMA_TOOLBAR_HEADINGS, QALMA_TOOLBAR_HISTORY, QALMA_TOOLBAR_INLINE_MARKS, QALMA_TOOLBAR_LISTS, QALMA_TOOLBAR_TABLE_INSERT, QALMA_TOOLBAR_TABLE_OPS, QALMA_TOOLBAR_UNSET_LINK, QalmaContextualToolbar, QalmaDragHandle, QalmaLinkPopover, QalmaMentionMenu, QalmaSlashCommandMenu, QalmaToolbarButton, QalmaToolbarRegistry, TOOLBAR_BUTTON_CLASS, TOOLBAR_SEPARATOR_CLASS, isToolbarCommandItem, provideQalmaToolbarIcons };
2467
1411
  //# sourceMappingURL=qalma-kit.mjs.map