@qalma/kit 0.3.0 → 0.5.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,70 +1,24 @@
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
62
15
  * entirely by the host's shadcn-style design tokens (`text-muted-foreground`,
63
- * `bg-secondary`, `bg-accent-subtle`…) so it themes with the surrounding app.
16
+ * `bg-card`, `bg-secondary`, `bg-accent-subtle`…) so it themes with the
17
+ * surrounding app.
64
18
  * The `qalma-command-active` class is toggled by the `QalmaCommand` directive
65
19
  * when the underlying command is active.
66
20
  */
67
- const TOOLBAR_BUTTON_CLASS = 'inline-flex h-[1.85rem] w-[1.85rem] cursor-pointer items-center justify-center rounded-[0.4rem] border border-transparent text-muted-foreground transition hover:bg-secondary hover:text-foreground disabled:cursor-not-allowed disabled:opacity-45 [&.qalma-command-active]:border-accent/40 [&.qalma-command-active]:bg-accent-subtle [&.qalma-command-active]:text-accent';
21
+ const TOOLBAR_BUTTON_CLASS = 'inline-flex h-[1.85rem] w-[1.85rem] cursor-pointer items-center justify-center rounded-[0.4rem] border border-border bg-card text-muted-foreground transition hover:border-accent/50 hover:bg-secondary hover:text-foreground disabled:cursor-not-allowed disabled:opacity-45 [&.qalma-command-active]:border-accent/40 [&.qalma-command-active]:bg-accent-subtle [&.qalma-command-active]:text-accent';
68
22
  /**
69
23
  * Generic, declarative toolbar button for a single editor command.
70
24
  *
@@ -368,500 +322,54 @@ function provideQalmaToolbarIcons() {
368
322
  });
369
323
  }
370
324
 
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
- }
325
+ function cn(...inputs) {
326
+ return twMerge(clsx(inputs));
452
327
  }
453
328
 
329
+ 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', {
330
+ variants: {
331
+ variant: {
332
+ default: 'bg-primary text-primary-foreground hover:bg-primary/90',
333
+ secondary: 'bg-secondary text-secondary-foreground hover:bg-secondary/80',
334
+ outline: 'border border-border bg-card text-foreground hover:bg-secondary hover:text-secondary-foreground',
335
+ ghost: 'text-foreground hover:bg-secondary hover:text-secondary-foreground',
336
+ accent: 'bg-accent text-accent-foreground hover:bg-accent/90',
337
+ link: 'text-accent underline-offset-4 hover:underline',
338
+ },
339
+ size: {
340
+ default: 'h-9 px-4 py-2',
341
+ sm: 'h-8 rounded-md px-3 text-xs',
342
+ lg: 'h-11 rounded-md px-6 text-base',
343
+ icon: 'h-9 w-9',
344
+ },
345
+ },
346
+ defaultVariants: {
347
+ variant: 'default',
348
+ size: 'default',
349
+ },
350
+ });
454
351
  /**
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.
352
+ * Headless-styled button directive so it composes onto native `<button>` and
353
+ * `<a>` elements while reading from the host's Tailwind design tokens.
462
354
  */
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);
355
+ class QalmaButton {
839
356
  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 });
357
+ initialClass = this.host.nativeElement.getAttribute('class') ?? '';
358
+ variant = input('default', ...(ngDevMode ? [{ debugName: "variant" }] : /* istanbul ignore next */ []));
359
+ size = input('default', ...(ngDevMode ? [{ debugName: "size" }] : /* istanbul ignore next */ []));
360
+ computedClass = computed(() => cn(buttonVariants({ variant: this.variant(), size: this.size() }), this.initialClass), ...(ngDevMode ? [{ debugName: "computedClass" }] : /* istanbul ignore next */ []));
361
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.17", ngImport: i0, type: QalmaButton, deps: [], target: i0.ɵɵFactoryTarget.Directive });
362
+ 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
363
  }
858
- i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.17", ngImport: i0, type: QalmaDragHandleDirective, decorators: [{
364
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.17", ngImport: i0, type: QalmaButton, decorators: [{
859
365
  type: Directive,
860
366
  args: [{
861
- exportAs: 'qalmaDragHandle',
862
- selector: '[qalmaDragHandle]',
367
+ selector: 'button[qalmaBtn], a[qalmaBtn]',
368
+ host: {
369
+ '[class]': 'computedClass()',
370
+ },
863
371
  }]
864
- }], ctorParameters: () => [], propDecorators: { editor: [{ type: i0.Input, args: [{ isSignal: true, alias: "qalmaDragHandle", required: true }] }] } });
372
+ }], propDecorators: { variant: [{ type: i0.Input, args: [{ isSignal: true, alias: "variant", required: false }] }], size: [{ type: i0.Input, args: [{ isSignal: true, alias: "size", required: false }] }] } });
865
373
 
866
374
  class QalmaDragHandle {
867
375
  editor = input.required(...(ngDevMode ? [{ debugName: "editor" }] : /* istanbul ignore next */ []));
@@ -1170,108 +678,6 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.17", ngImpo
1170
678
  }]
1171
679
  }], 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
680
 
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
681
  /**
1276
682
  * Caret-anchored mention suggestion menu: an avatar-initial list with a loading
1277
683
  * state. Positioning, keyboard handling and active highlighting come from
@@ -1666,148 +1072,6 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.17", ngImpo
1666
1072
  }]
1667
1073
  }], ctorParameters: () => [], propDecorators: { options: [{ type: i0.Input, args: [{ isSignal: true, alias: "options", required: false }] }] } });
1668
1074
 
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
1075
  class QalmaLinkPopover {
1812
1076
  popover = input(null, ...(ngDevMode ? [{ debugName: "popover" }] : /* istanbul ignore next */ []));
1813
1077
  href = input.required(...(ngDevMode ? [{ debugName: "href" }] : /* istanbul ignore next */ []));
@@ -2056,13 +1320,34 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.17", ngImpo
2056
1320
  }]
2057
1321
  }], 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
1322
 
1323
+ /**
1324
+ * Floating, selection-anchored toolbar container. It owns the hard parts — the
1325
+ * fixed-position `transform` from a {@link QalmaContextualToolbarPlacement}
1326
+ * (see `QalmaSelectionToolbarDirective`), preserving the editor selection on
1327
+ * pointer-down, and Escape-to-dismiss — and projects whatever controls you put
1328
+ * inside it. Nothing about the button set is baked in: compose
1329
+ * `<qalma-toolbar-button>` for commands and your own buttons for app actions
1330
+ * (opening a link editor, a color picker, an AI action…).
1331
+ *
1332
+ * The container's `mousedown` handler preserves the selection for every
1333
+ * projected control via event bubbling, so inner buttons don't each need to.
1334
+ *
1335
+ * ```html
1336
+ * <qalma-contextual-toolbar
1337
+ * [placement]="selection.placement()"
1338
+ * (dismiss)="selection.hide()"
1339
+ * >
1340
+ * <qalma-toolbar-button command="toggleBold" icon="lucideBold" label="Bold" />
1341
+ * <qalma-toolbar-button command="toggleItalic" icon="lucideItalic" label="Italic" />
1342
+ * <button type="button" [class]="TOOLBAR_BUTTON_CLASS" (click)="openLinkEditor()">…</button>
1343
+ * </qalma-contextual-toolbar>
1344
+ * ```
1345
+ */
2059
1346
  class QalmaContextualToolbar {
2060
- editor = input.required(...(ngDevMode ? [{ debugName: "editor" }] : /* istanbul ignore next */ []));
2061
1347
  placement = input(null, ...(ngDevMode ? [{ debugName: "placement" }] : /* istanbul ignore next */ []));
2062
- requestLink = output();
1348
+ /** Accessible name for the toolbar region. */
1349
+ label = input('Selection formatting', ...(ngDevMode ? [{ debugName: "label" }] : /* istanbul ignore next */ []));
2063
1350
  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
1351
  preserveSelection(event) {
2067
1352
  event.preventDefault();
2068
1353
  }
@@ -2071,397 +1356,57 @@ class QalmaContextualToolbar {
2071
1356
  this.dismiss.emit();
2072
1357
  }
2073
1358
  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: `
1359
+ 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
1360
  @if (placement(); as placement) {
2084
1361
  <qalma-toolbar
2085
- label="Selection formatting"
1362
+ [label]="label()"
2086
1363
  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
1364
  [style.transform]="placement.transform"
2088
1365
  (mousedown)="preserveSelection($event)"
2089
1366
  (keydown.escape)="dismissFromKeyboard($event)"
2090
1367
  >
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>
1368
+ <ng-content />
2176
1369
  </qalma-toolbar>
2177
1370
  }
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 });
1371
+ `, isInline: true, dependencies: [{ kind: "component", type: QalmaToolbar, selector: "qalma-toolbar", inputs: ["label"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
2179
1372
  }
2180
1373
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.17", ngImport: i0, type: QalmaContextualToolbar, decorators: [{
2181
1374
  type: Component,
2182
1375
  args: [{
2183
1376
  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
- ],
1377
+ imports: [QalmaToolbar],
2194
1378
  selector: 'qalma-contextual-toolbar',
2195
1379
  template: `
2196
1380
  @if (placement(); as placement) {
2197
1381
  <qalma-toolbar
2198
- label="Selection formatting"
1382
+ [label]="label()"
2199
1383
  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
1384
  [style.transform]="placement.transform"
2201
1385
  (mousedown)="preserveSelection($event)"
2202
1386
  (keydown.escape)="dismissFromKeyboard($event)"
2203
1387
  >
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>
1388
+ <ng-content />
2289
1389
  </qalma-toolbar>
2290
1390
  }
2291
1391
  `,
2292
1392
  }]
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
- }
1393
+ }], 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
1394
 
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 }] }] } });
1395
+ // @qalma/kit — Tailwind/shadcn-flavored UI for the Qalma editor.
1396
+ //
1397
+ // This entry point is the styled tier: components that pull `cva` + `@ng-icons`
1398
+ // and read the host's design tokens. It re-exports the entire dependency-free
1399
+ // `@qalma/kit/headless` surface (controllers, geometry, keyboard nav) so it
1400
+ // stays a superset — bring-your-own-design-system consumers can import the
1401
+ // leaner `@qalma/kit/headless` directly instead.
1402
+ //
1403
+ // The internal `QalmaButton` directive and `cn()` helper are intentionally NOT
1404
+ // exported: a host already has a button and a class-merge utility in its design
1405
+ // system. They remain private implementation details of the styled components.
2461
1406
 
2462
1407
  /**
2463
1408
  * Generated bundle index. Do not edit.
2464
1409
  */
2465
1410
 
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 };
1411
+ 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
1412
  //# sourceMappingURL=qalma-kit.mjs.map