# LinkToolbarComponent

Import it from `@grafloria/angular`.

Card B — a floating, PATH-ANCHORED action layer.

Mirrors NodeToolbarComponent, but an edge has no bounding box to hang off:
it is anchored to a FRACTION along the link (default the midpoint) and lifted
off the stroke along the path NORMAL, so it never sits on top of the line it
acts on. It re-anchors itself whenever the route changes — pan, zoom, a node
drag that reroutes the edge, a waypoint edit.

Geometry comes from RenderedLinkPath (the drawn <path>, or the per-frame
point polyline), never from LinkModel.segments — see that file for why.

```ts
@Component({
  selector: 'grafloria-link-toolbar',
  standalone: true,
  imports: [CommonModule],
  template: `
    <div
      #toolbar
      class="grafloria-link-toolbar"
      role="toolbar"
      [attr.aria-label]="ariaLabel"
      [attr.aria-hidden]="!isVisible"
      [attr.data-link-id]="link?.id"
      [class.visible]="isVisible"
      [class.animated]="animation?.enabled !== false"
      [style.transform]="transform"
      [style.opacity]="isVisible ? 1 : 0"
      [style.pointer-events]="isVisible ? 'all' : 'none'"
      [style.--toolbar-bg]="styleConfig?.backgroundColor"
      [style.--toolbar-border]="styleConfig?.borderColor"
      [style.--toolbar-radius]="styleConfig?.borderRadius"
      [style.--toolbar-shadow]="styleConfig?.boxShadow"
      [style.--toolbar-padding]="styleConfig?.padding"
      [style.--toolbar-z]="styleConfig?.zIndex"
      [style.--toolbar-transition]="animation?.duration || '0.15s'"
      (mouseenter)="onPointerEnter()"
      (mouseleave)="onPointerLeave()"
      (keydown)="handleKeyDown($event)"
    >
      @if (!customTemplate) {
        <div class="toolbar-content" role="group">
          @for (action of visibleActions; track action.id; let idx = $index) {
            <button
              type="button"
              class="toolbar-button"
              [attr.aria-label]="action.tooltip || action.label"
              [attr.aria-disabled]="action.disabled"
              [attr.data-action-id]="action.id"
              [attr.tabindex]="idx === focusedActionIndex ? 0 : -1"
              [disabled]="action.disabled"
              [title]="action.tooltip || action.label"
              (click)="handleActionClick(action, $event)"
              (focus)="focusedActionIndex = idx"
            >
              @if (action.icon) {
                <i [class]="action.icon" aria-hidden="true"></i>
              }
              <span>{{ action.label }}</span>
            </button>
          }
        </div>
      }

      @if (customTemplate) {
        <ng-container
          *ngTemplateOutlet="customTemplate; context: { $implicit: link, actions: visibleActions, context: actionContext }">
        </ng-container>
      }
    </div>
  `,
  styles: [`
    .grafloria-link-toolbar {
      position: absolute;
      top: 0;
      left: 0;
      display: flex;
      gap: 4px;
      background: var(--toolbar-bg, var(--grafloria-toolbar-bg, white));
      border: 1px solid var(--toolbar-border, var(--grafloria-toolbar-border, #e2e8f0));
      border-radius: var(--toolbar-radius, var(--grafloria-toolbar-radius, 8px));
      box-shadow: var(--toolbar-shadow, var(--grafloria-toolbar-shadow, 0 4px 6px rgba(0, 0, 0, 0.1)));
      padding: var(--toolbar-padding, var(--grafloria-toolbar-padding, 2px));
      z-index: var(--toolbar-z, var(--grafloria-toolbar-z, 1000));
      outline: none;
      white-space: nowrap;
    }

    .grafloria-link-toolbar.animated {
      transition: opacity var(--toolbar-transition, 0.15s) ease;
    }

    .grafloria-link-toolbar:focus-within {
      outline: 2px solid var(--grafloria-focus-color, #667eea);
      outline-offset: 2px;
    }

    .toolbar-content {
      display: flex;
      gap: 2px;
    }

    .toolbar-button {
      display: flex;
      align-items: center;
      gap: 4px;
      padding: 4px 8px;
      background: transparent;
      border: none;
      border-radius: 4px;
      cursor: pointer;
      font-size: 12px;
      line-height: 1.4;
      color: var(--grafloria-toolbar-text, #334155);
      transition: background-color 0.15s ease;
      white-space: nowrap;
    }

    .toolbar-button:hover:not(:disabled) {
      background: var(--grafloria-toolbar-hover, #f1f5f9);
    }

    .toolbar-button:active:not(:disabled) {
      background: var(--grafloria-toolbar-active, #e2e8f0);
    }

    .toolbar-button:focus {
      outline: 2px solid var(--grafloria-focus-color, #667eea);
      outline-offset: -2px;
    }

    .toolbar-button:disabled,
    …
```

Use it as `<grafloria-link-toolbar>` in a template.

**Inputs**

| Name | Type | Default | Description |
| --- | --- | --- | --- |
| `link?` | `LinkModel` |  | The link this toolbar acts on. |
| `engine?` | `DiagramEngine` |  |  |
| `canvasElement?` | `HTMLElement` |  | Canvas container the toolbar is positioned inside (and measured against). |
| `viewport?` | `{ x: number; y: number; width: number; height: number }` |  | Same viewport/zoom the canvas renders with — the screen-space fallback. |
| `zoom?` |  |  |  |
| `anchor?` |  |  | Fraction along the path to glue the toolbar to (0 = source, 1 = target). |
| `offset?` |  |  | Screen-px lift along the path normal, so the buttons clear the stroke. |
| `actions?` | `LinkToolbarAction[]` |  |  |
| `customTemplate?` | `TemplateRef<any>` |  |  |
| `visible?` |  |  |  |
| `styleConfig?` | `ToolbarStyleConfig` |  |  |
| `animation?` | `ToolbarAnimationConfig` |  |  |
| `ariaLabel?` |  |  |  |

**Outputs**

- `actionClicked`
- `positionUpdated`
- `pointerOverChange` — Emits while the pointer is over the toolbar — lets the host keep it alive

**Properties**

| Name | Type | Default | Description |
| --- | --- | --- | --- |
| `link` | `LinkModel` |  | The link this toolbar acts on. |
| `engine` | `DiagramEngine` |  |  |
| `canvasElement?` | `HTMLElement` |  | Canvas container the toolbar is positioned inside (and measured against). |
| `viewport` | `{ x: number; y: number; width: number; height: number }` |  | Same viewport/zoom the canvas renders with — the screen-space fallback. |
| `zoom` |  | `1.0` |  |
| `anchor` |  | `0.5` | Fraction along the path to glue the toolbar to (0 = source, 1 = target). |
| `offset` |  | `22` | Screen-px lift along the path normal, so the buttons clear the stroke. |
| `actions` | `LinkToolbarAction[]` | `[]` |  |
| `customTemplate?` | `TemplateRef<any>` |  |  |
| `visible` |  | `true` |  |
| `styleConfig?` | `ToolbarStyleConfig` |  |  |
| `animation?` | `ToolbarAnimationConfig` |  |  |
| `ariaLabel` |  | `'Link actions'` |  |
| `actionClicked` |  |  |  |
| `positionUpdated` |  |  |  |
| `pointerOverChange` |  |  | Emits while the pointer is over the toolbar — lets the host keep it alive when the pointer leaves the link itself to reach for a button. |
| `toolbarRef?` | `ElementRef<HTMLDivElement>` |  |  |
| `isVisible` |  | `false` |  |
| `transform` |  | `''` |  |
| `focusedActionIndex` |  | `0` |  |

**Methods**

- `constructor(private cdr: ChangeDetectorRef, private host: ElementRef<HTMLElement>)`
- `ngOnInit(): void`
- `ngOnChanges(changes: SimpleChanges): void`
- `ngOnDestroy(): void`
- `get visibleActions(): LinkToolbarAction[]`
- `get actionContext(): LinkActionContext | null` — What every button is handed: the link, WHERE on it, and in which direction.
- `handleActionClick(action: LinkToolbarAction, event?: Event): void`
- `handleKeyDown(event: KeyboardEvent): void`
- `onPointerEnter(): void`
- `onPointerLeave(): void`
- `updatePosition(): void` — Re-anchor to the CURRENT rendered route.

Runs the whole chain: rendered path → point + normal at `anchor` → screen
px → centred, normal-offset transform. Any missing link in that chain hides
the toolbar rather than parking it at (0, 0).

**Example**

```html
<grafloria-link-toolbar
  [link]="hoveredLink" [engine]="engine"
  [viewport]="viewport" [zoom]="zoom" [anchor]="0.5">
</grafloria-link-toolbar>
```
