ScreenNode.svelte 4.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150
  1. <script lang="ts">
  2. /**
  3. * One screen on the Screens view: its path, and the component that renders
  4. * it. An origin — a function that navigates but belongs to no screen (a
  5. * store action after login) — draws dashed, so it reads as a trigger rather
  6. * than a place. The entry screen (`/`) carries a mark.
  7. *
  8. * Hidden handles along the top and bottom, one per port, exactly as the
  9. * Map's module box does — except that here a side may hold both kinds. The
  10. * layout routed every transition (`directional` ports): a return trip
  11. * leaves the TOP of this box and arrives at the BOTTOM of the screen it
  12. * returns to, so the line runs around the boxes instead of through them.
  13. * This only draws the ports the layout decided.
  14. */
  15. import { Handle, Position, type NodeProps } from '@xyflow/svelte';
  16. import type { MapNodeLayout } from '../../lib/map-model';
  17. import type { ScreenNodeInfo } from '../../lib/screens-model';
  18. let { data }: NodeProps = $props();
  19. const node = $derived(
  20. data as unknown as {
  21. layout: MapNodeLayout;
  22. info: ScreenNodeInfo;
  23. selected: boolean;
  24. dimmed: boolean;
  25. onSelect: (id: string) => void;
  26. /** Open what happens from this screen (the Steps picture) — a double-click. */
  27. onOpen?: (id: string) => void;
  28. }
  29. );
  30. const layout = $derived(node.layout);
  31. const info = $derived(node.info);
  32. function portStyle(index: number, total: number): string {
  33. return `left:${((index + 1) / (total + 1)) * 100}%`;
  34. }
  35. </script>
  36. {#each layout.ports.top as port, i (`${port.type}:${port.id}`)}
  37. <Handle
  38. type={port.type}
  39. id={`${port.type === 'source' ? 's' : 't'}:${port.id}`}
  40. position={Position.Top}
  41. style={portStyle(i, layout.ports.top.length)}
  42. isConnectable={false}
  43. />
  44. {/each}
  45. <button
  46. class="snode"
  47. class:sel={node.selected}
  48. class:dimmed={node.dimmed}
  49. class:origin={info.origin}
  50. class:entry={info.entry}
  51. class:unreached={info.unreached}
  52. style={`width:${layout.width}px;height:${layout.height}px`}
  53. onclick={() => node.onSelect(info.id)}
  54. ondblclickcapture={(e) => {
  55. // The flow canvas zooms on a double-click that reaches its pane; a
  56. // double-click on a box is a navigation, not a zoom — stop it here,
  57. // at the target, before it bubbles. The pane's own double-click keeps zooming.
  58. e.stopPropagation();
  59. node.onOpen?.(info.id);
  60. }}
  61. aria-pressed={node.selected}
  62. title={(info.origin
  63. ? `${info.label} — navigates, but no screen reaches it within the walk. In ${info.sub}.`
  64. : `${info.label} — rendered by ${info.sub}${info.entry ? '. The entry screen.' : ''}${
  65. info.unreached ? '. No transition in the graph reaches it from the entry.' : ''
  66. }`) + (node.onOpen ? ' Double-click for what happens here.' : '')}
  67. >
  68. <span class="name">{#if info.entry}<span class="mark" aria-hidden="true">●</span>{/if}{info.label}</span>
  69. <span class="sub">{info.sub}</span>
  70. </button>
  71. {#each layout.ports.bottom as port, i (`${port.type}:${port.id}`)}
  72. <Handle
  73. type={port.type}
  74. id={`${port.type === 'source' ? 's' : 't'}:${port.id}`}
  75. position={Position.Bottom}
  76. style={portStyle(i, layout.ports.bottom.length)}
  77. isConnectable={false}
  78. />
  79. {/each}
  80. <style>
  81. .snode {
  82. display: flex;
  83. flex-direction: column;
  84. justify-content: center;
  85. gap: 1px;
  86. box-sizing: border-box;
  87. padding: 0 9px;
  88. border: 1px solid var(--ink);
  89. border-radius: 0;
  90. background: var(--paper);
  91. text-align: left;
  92. cursor: pointer;
  93. font: inherit;
  94. color: var(--ink);
  95. transition: background 90ms linear;
  96. }
  97. .snode:hover,
  98. .snode.sel {
  99. border-width: 2px;
  100. padding: 0 8px;
  101. background: var(--press);
  102. }
  103. .snode.dimmed {
  104. border-color: var(--ink-4);
  105. color: var(--ink-4);
  106. }
  107. .snode.dimmed .sub {
  108. color: var(--ink-4);
  109. }
  110. .snode.origin {
  111. border-style: dashed;
  112. border-color: var(--ink-3);
  113. }
  114. .snode.unreached {
  115. border-color: var(--ink-4);
  116. color: var(--ink-2);
  117. }
  118. .snode:focus-visible {
  119. outline: 2px solid var(--accent);
  120. outline-offset: 1px;
  121. }
  122. .name {
  123. font: 500 13px var(--mono);
  124. line-height: 15px;
  125. white-space: nowrap;
  126. overflow: hidden;
  127. text-overflow: ellipsis;
  128. }
  129. .mark {
  130. color: var(--accent);
  131. margin-right: 5px;
  132. font-size: 9px;
  133. vertical-align: 1px;
  134. }
  135. .sub {
  136. font: 400 11px var(--sans);
  137. line-height: 13px;
  138. color: var(--ink-3);
  139. white-space: nowrap;
  140. overflow: hidden;
  141. text-overflow: ellipsis;
  142. }
  143. </style>