@railway/inkwell 1.0.0 → 1.1.1

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.
package/README.md CHANGED
@@ -51,26 +51,18 @@ pnpm dev
51
51
 
52
52
  ## Releases
53
53
 
54
- Versioning and `CHANGELOG.md` are managed by [Changesets](https://github.com/changesets/changesets). Publishing to npm runs via GitHub Actions on a `v*` tag push.
54
+ Releases are automated. Every PR carries exactly one label that determines the next version bump:
55
55
 
56
- When you open a PR that should show up in the changelog:
56
+ | Label | Effect |
57
+ |---|---|
58
+ | `release/patch` | Bumps the patch version (e.g. `1.1.0` → `1.1.1`) |
59
+ | `release/minor` | Bumps the minor version (e.g. `1.1.0` → `1.2.0`) |
60
+ | `release/major` | Bumps the major version (e.g. `1.1.0` → `2.0.0`) |
61
+ | `release/skip` | No version bump (use for docs-only or chore PRs) |
57
62
 
58
- ```bash
59
- pnpm changeset # pick bump type, write a user-facing summary
60
- ```
61
-
62
- Commit the generated `.changeset/*.md` file alongside your code.
63
-
64
- To cut a release from `main`:
65
-
66
- ```bash
67
- pnpm changeset version # bumps packages/inkwell/package.json + writes CHANGELOG.md
68
- git commit -am "🚀 release: v$(node -p "require('./packages/inkwell/package.json').version")"
69
- git tag "v$(node -p "require('./packages/inkwell/package.json').version")"
70
- git push --follow-tags
71
- ```
63
+ When a labeled PR merges to `main`, the `auto-release` workflow batches with any other recently-merged PRs, picks the highest bump across them, tags `vX.Y.Z`, and pushes. The tag push triggers the `publish` workflow, which generates release notes from PR titles since the previous tag, drafts a GitHub Release, publishes to npm, and un-drafts.
72
64
 
73
- Pushing the `v*` tag triggers the publish workflow.
65
+ Release notes live on the [GitHub Releases page](https://github.com/railwayapp/inkwell/releases). There is no in-repo `CHANGELOG.md`.
74
66
 
75
67
  ## License
76
68
 
package/dist/index.cjs CHANGED
@@ -148,7 +148,7 @@ function BubbleMenuWidget({
148
148
  left: position.left,
149
149
  transform: position.placement === "above" ? "translateX(-50%) translateY(-100%)" : "translateX(-50%)",
150
150
  marginTop: position.placement === "above" ? -8 : 8,
151
- zIndex: 1100
151
+ zIndex: 9999
152
152
  },
153
153
  onMouseDown: (e) => e.preventDefault(),
154
154
  children: /* @__PURE__ */ jsxRuntime.jsx("div", { className: cls("inner"), children: items.map((item) => /* @__PURE__ */ jsxRuntime.jsx(item.render, { wrapSelection }, item.key)) })
@@ -1436,7 +1436,8 @@ var InkwellEditorClient = react.forwardRef(
1436
1436
  activePluginRef.current = activePlugin;
1437
1437
  const pluginPositionRef = react.useRef({
1438
1438
  top: 0,
1439
- left: 0
1439
+ left: 0,
1440
+ cursorRect: { top: 0, bottom: 0, left: 0 }
1440
1441
  });
1441
1442
  const forwardedKeyListenersRef = react.useRef(/* @__PURE__ */ new Map());
1442
1443
  const wrapperRef = react.useRef(null);
@@ -1547,10 +1548,14 @@ var InkwellEditorClient = react.forwardRef(
1547
1548
  ]
1548
1549
  );
1549
1550
  const getCursorPosition = react.useCallback(() => {
1551
+ const empty = {
1552
+ top: 0,
1553
+ left: 0,
1554
+ cursorRect: { top: 0, bottom: 0, left: 0 }
1555
+ };
1550
1556
  try {
1551
1557
  const domSelection = window.getSelection();
1552
- if (!domSelection || domSelection.rangeCount === 0)
1553
- return { top: 0, left: 0 };
1558
+ if (!domSelection || domSelection.rangeCount === 0) return empty;
1554
1559
  const range = domSelection.getRangeAt(0);
1555
1560
  let rect = range.getBoundingClientRect();
1556
1561
  if (rect.width === 0 && rect.height === 0 && domSelection.anchorNode) {
@@ -1558,14 +1563,22 @@ var InkwellEditorClient = react.forwardRef(
1558
1563
  if (node) rect = node.getBoundingClientRect();
1559
1564
  }
1560
1565
  const wrapperEl = wrapperRef.current;
1561
- if (!wrapperEl) return { top: 0, left: 0 };
1566
+ if (!wrapperEl) return empty;
1562
1567
  const wrapperRect = wrapperEl.getBoundingClientRect();
1568
+ const cursorTop = rect.top - wrapperRect.top;
1569
+ const cursorBottom = rect.bottom - wrapperRect.top;
1570
+ const cursorLeft = rect.left - wrapperRect.left;
1563
1571
  return {
1564
- top: rect.bottom - wrapperRect.top + 4,
1565
- left: rect.left - wrapperRect.left
1572
+ top: cursorBottom + 4,
1573
+ left: cursorLeft,
1574
+ cursorRect: {
1575
+ top: cursorTop,
1576
+ bottom: cursorBottom,
1577
+ left: cursorLeft
1578
+ }
1566
1579
  };
1567
1580
  } catch {
1568
- return { top: 0, left: 0 };
1581
+ return empty;
1569
1582
  }
1570
1583
  }, []);
1571
1584
  const wrapSelection = react.useCallback(
@@ -1833,7 +1846,11 @@ var InkwellEditorClient = react.forwardRef(
1833
1846
  query: activePlugin === plugin ? activePluginQuery : "",
1834
1847
  onSelect: handlePluginSelect,
1835
1848
  onDismiss: dismissPlugin,
1836
- position: pluginPositionRef.current,
1849
+ position: {
1850
+ top: pluginPositionRef.current.top,
1851
+ left: pluginPositionRef.current.left
1852
+ },
1853
+ cursorRect: pluginPositionRef.current.cursorRect,
1837
1854
  editorRef: editorElRef,
1838
1855
  editor: pluginEditor,
1839
1856
  wrapSelection,
@@ -2273,8 +2290,10 @@ function createCompletionsPlugin({
2273
2290
  var BASE = "inkwell-plugin-picker";
2274
2291
  var pluginPickerClass = {
2275
2292
  popup: `${BASE}-popup`,
2293
+ popupFlipped: `${BASE}-popup-flipped`,
2276
2294
  picker: `${BASE}`,
2277
2295
  search: `${BASE}-search`,
2296
+ list: `${BASE}-list`,
2278
2297
  item: `${BASE}-item`,
2279
2298
  itemActive: `${BASE}-item-active`,
2280
2299
  empty: `${BASE}-empty`,
@@ -2282,6 +2301,74 @@ var pluginPickerClass = {
2282
2301
  subtitle: `${BASE}-subtitle`,
2283
2302
  preview: `${BASE}-preview`
2284
2303
  };
2304
+ var POPUP_GAP = 4;
2305
+ var VIEWPORT_MARGIN = 8;
2306
+ var CURSOR_HEIGHT_FALLBACK = 20;
2307
+ function usePickerPlacement(position, cursorRect) {
2308
+ const [popupEl, setPopupEl] = react.useState(null);
2309
+ const [placement, setPlacement] = react.useState({ flippedAbove: false, leftOverride: null });
2310
+ const cursorTopWrapper = cursorRect?.top ?? Math.max(0, position.top - POPUP_GAP - CURSOR_HEIGHT_FALLBACK);
2311
+ const cursorBottomWrapper = cursorRect?.bottom ?? Math.max(0, position.top - POPUP_GAP);
2312
+ const cursorLeftWrapper = cursorRect?.left ?? position.left;
2313
+ react.useLayoutEffect(() => {
2314
+ if (!popupEl) return;
2315
+ const measure = () => {
2316
+ const offsetParent = popupEl.offsetParent;
2317
+ if (!(offsetParent instanceof HTMLElement)) return;
2318
+ const wrapperRect = offsetParent.getBoundingClientRect();
2319
+ const popupRect = popupEl.getBoundingClientRect();
2320
+ const popupHeight = popupRect.height;
2321
+ const popupWidth = popupRect.width;
2322
+ const cursorTopViewport = wrapperRect.top + cursorTopWrapper;
2323
+ const cursorBottomViewport = wrapperRect.top + cursorBottomWrapper;
2324
+ const cursorLeftViewport = wrapperRect.left + cursorLeftWrapper;
2325
+ const spaceBelowViewport = window.innerHeight - cursorBottomViewport - VIEWPORT_MARGIN;
2326
+ const spaceBelowWrapper = wrapperRect.bottom - cursorBottomViewport;
2327
+ const spaceBelow = Math.min(spaceBelowViewport, spaceBelowWrapper);
2328
+ const spaceAbove = cursorTopViewport - VIEWPORT_MARGIN;
2329
+ const needsFlip = popupHeight + POPUP_GAP > spaceBelow;
2330
+ const fitsAbove = popupHeight + POPUP_GAP <= spaceAbove;
2331
+ const flippedAbove = needsFlip && fitsAbove;
2332
+ const maxRightViewport = window.innerWidth - VIEWPORT_MARGIN;
2333
+ const popupRightIfDefault = cursorLeftViewport + popupWidth;
2334
+ const leftOverride = popupRightIfDefault > maxRightViewport ? Math.max(
2335
+ VIEWPORT_MARGIN - wrapperRect.left,
2336
+ maxRightViewport - popupWidth - wrapperRect.left
2337
+ ) : null;
2338
+ setPlacement(
2339
+ (prev) => prev.flippedAbove === flippedAbove && prev.leftOverride === leftOverride ? prev : { flippedAbove, leftOverride }
2340
+ );
2341
+ };
2342
+ measure();
2343
+ const ResizeObserverCtor = typeof window !== "undefined" ? window.ResizeObserver : void 0;
2344
+ const resizeObserver = ResizeObserverCtor ? new ResizeObserverCtor(() => measure()) : null;
2345
+ resizeObserver?.observe(popupEl);
2346
+ window.addEventListener("resize", measure);
2347
+ window.addEventListener("scroll", measure, true);
2348
+ return () => {
2349
+ resizeObserver?.disconnect();
2350
+ window.removeEventListener("resize", measure);
2351
+ window.removeEventListener("scroll", measure, true);
2352
+ };
2353
+ }, [popupEl, cursorTopWrapper, cursorBottomWrapper, cursorLeftWrapper]);
2354
+ const style = {
2355
+ position: "absolute",
2356
+ top: placement.flippedAbove ? cursorTopWrapper - POPUP_GAP : position.top,
2357
+ left: placement.leftOverride ?? position.left,
2358
+ transform: placement.flippedAbove ? "translateY(-100%)" : void 0,
2359
+ zIndex: 1001
2360
+ };
2361
+ const className = placement.flippedAbove ? `${pluginPickerClass.popup} ${pluginPickerClass.popupFlipped}` : pluginPickerClass.popup;
2362
+ return {
2363
+ setPopupEl,
2364
+ style,
2365
+ className,
2366
+ flippedAbove: placement.flippedAbove
2367
+ };
2368
+ }
2369
+ function usePluginPopupPlacement(position, cursorRect) {
2370
+ return usePickerPlacement(position, cursorRect);
2371
+ }
2285
2372
  function PluginMenuPrimitive({
2286
2373
  items,
2287
2374
  search,
@@ -2293,6 +2380,7 @@ function PluginMenuPrimitive({
2293
2380
  onSelect,
2294
2381
  onDismiss,
2295
2382
  position,
2383
+ cursorRect,
2296
2384
  subscribeForwardedKey
2297
2385
  }) {
2298
2386
  const [selectedIndex, setSelectedIndex] = react.useState(0);
@@ -2437,16 +2525,13 @@ function PluginMenuPrimitive({
2437
2525
  updateSelectedIndex
2438
2526
  ]
2439
2527
  );
2528
+ const placement = usePickerPlacement(position, cursorRect);
2440
2529
  return /* @__PURE__ */ jsxRuntime.jsx(
2441
2530
  "div",
2442
2531
  {
2443
- className: pluginPickerClass.popup,
2444
- style: {
2445
- position: "absolute",
2446
- top: position.top,
2447
- left: position.left,
2448
- zIndex: 1001
2449
- },
2532
+ ref: placement.setPopupEl,
2533
+ className: placement.className,
2534
+ style: placement.style,
2450
2535
  onMouseDown: (event) => event.preventDefault(),
2451
2536
  children: /* @__PURE__ */ jsxRuntime.jsxs(
2452
2537
  "div",
@@ -2460,7 +2545,7 @@ function PluginMenuPrimitive({
2460
2545
  "aria-activedescendant": results.length > 0 ? activeOptionId : void 0,
2461
2546
  children: [
2462
2547
  /* @__PURE__ */ jsxRuntime.jsx("div", { className: pluginPickerClass.search, "aria-label": placeholder, children: query || placeholder }),
2463
- results.length === 0 ? /* @__PURE__ */ jsxRuntime.jsx("div", { className: pluginPickerClass.empty, role: "status", children: emptyMessage }) : /* @__PURE__ */ jsxRuntime.jsx("div", { id: listboxId, role: "listbox", children: renderedResults })
2548
+ results.length === 0 ? /* @__PURE__ */ jsxRuntime.jsx("div", { className: pluginPickerClass.empty, role: "status", children: emptyMessage }) : /* @__PURE__ */ jsxRuntime.jsx("div", { id: listboxId, role: "listbox", className: pluginPickerClass.list, children: renderedResults })
2464
2549
  ]
2465
2550
  }
2466
2551
  )
@@ -2622,6 +2707,7 @@ var SlashCommandMenuInner = react.forwardRef(function SlashCommandMenuInner2({
2622
2707
  onExecute,
2623
2708
  onDismiss,
2624
2709
  position,
2710
+ cursorRect,
2625
2711
  getEditor
2626
2712
  }, ref) {
2627
2713
  const [mode, setMode] = react.useState("commands");
@@ -2785,17 +2871,14 @@ var SlashCommandMenuInner = react.forwardRef(function SlashCommandMenuInner2({
2785
2871
  selectedIndex
2786
2872
  ]
2787
2873
  );
2874
+ const placement = usePluginPopupPlacement(position, cursorRect);
2788
2875
  if (mode === "ready" && selectedCommand) {
2789
2876
  return /* @__PURE__ */ jsxRuntime.jsx(
2790
2877
  "div",
2791
2878
  {
2792
- className: pluginPickerClass.popup,
2793
- style: {
2794
- position: "absolute",
2795
- top: position.top,
2796
- left: position.left,
2797
- zIndex: 1001
2798
- },
2879
+ ref: placement.setPopupEl,
2880
+ className: placement.className,
2881
+ style: placement.style,
2799
2882
  children: /* @__PURE__ */ jsxRuntime.jsx("div", { className: pluginPickerClass.picker, children: /* @__PURE__ */ jsxRuntime.jsx("div", { className: "inkwell-plugin-slash-commands-execute", children: "Enter to execute \xB7 Esc to cancel" }) })
2800
2883
  }
2801
2884
  );
@@ -2803,13 +2886,9 @@ var SlashCommandMenuInner = react.forwardRef(function SlashCommandMenuInner2({
2803
2886
  return /* @__PURE__ */ jsxRuntime.jsx(
2804
2887
  "div",
2805
2888
  {
2806
- className: pluginPickerClass.popup,
2807
- style: {
2808
- position: "absolute",
2809
- top: position.top,
2810
- left: position.left,
2811
- zIndex: 1001
2812
- },
2889
+ ref: placement.setPopupEl,
2890
+ className: placement.className,
2891
+ style: placement.style,
2813
2892
  children: /* @__PURE__ */ jsxRuntime.jsxs("div", { className: pluginPickerClass.picker, children: [
2814
2893
  /* @__PURE__ */ jsxRuntime.jsx("div", { className: pluginPickerClass.search, children: mode === "commands" ? `/${query}` : `${selectedCommand ? `/${selectedCommand.name} ` : ""}${query}` }),
2815
2894
  loadingArgs && items.length === 0 ? /* @__PURE__ */ jsxRuntime.jsx("div", { className: pluginPickerClass.empty, children: "Loading..." }) : items.length === 0 ? /* @__PURE__ */ jsxRuntime.jsx("div", { className: pluginPickerClass.empty, children: emptyMessage }) : /* @__PURE__ */ jsxRuntime.jsx(
@@ -2818,6 +2897,7 @@ var SlashCommandMenuInner = react.forwardRef(function SlashCommandMenuInner2({
2818
2897
  id: listboxId,
2819
2898
  role: "listbox",
2820
2899
  "aria-activedescendant": activeOptionId,
2900
+ className: pluginPickerClass.list,
2821
2901
  children: items.map((item, index) => {
2822
2902
  const active = index === selectedIndex;
2823
2903
  return /* @__PURE__ */ jsxRuntime.jsxs(
package/dist/index.d.cts CHANGED
@@ -180,11 +180,20 @@ interface PluginRenderProps {
180
180
  onSelect: (content: string) => void;
181
181
  /** Deactivate this plugin. */
182
182
  onDismiss: () => void;
183
- /** Cursor position when the trigger fired. */
183
+ /** Cursor position when the trigger fired. Wrapper-relative; sits just
184
+ * below the caret so a popup with `top: position.top` renders below it. */
184
185
  position: {
185
186
  top: number;
186
187
  left: number;
187
188
  };
189
+ /** Wrapper-relative caret bounding rect when the trigger fired. Lets pickers
190
+ * flip above the caret when there isn't room below. Optional for backwards
191
+ * compatibility with consumers that pass synthesized props. */
192
+ cursorRect?: {
193
+ top: number;
194
+ bottom: number;
195
+ left: number;
196
+ };
188
197
  /** Ref to the editable DOM element. */
189
198
  editorRef: RefObject<HTMLDivElement | null>;
190
199
  /** Narrow editor controller for plugin actions. */
package/dist/index.d.ts CHANGED
@@ -180,11 +180,20 @@ interface PluginRenderProps {
180
180
  onSelect: (content: string) => void;
181
181
  /** Deactivate this plugin. */
182
182
  onDismiss: () => void;
183
- /** Cursor position when the trigger fired. */
183
+ /** Cursor position when the trigger fired. Wrapper-relative; sits just
184
+ * below the caret so a popup with `top: position.top` renders below it. */
184
185
  position: {
185
186
  top: number;
186
187
  left: number;
187
188
  };
189
+ /** Wrapper-relative caret bounding rect when the trigger fired. Lets pickers
190
+ * flip above the caret when there isn't room below. Optional for backwards
191
+ * compatibility with consumers that pass synthesized props. */
192
+ cursorRect?: {
193
+ top: number;
194
+ bottom: number;
195
+ left: number;
196
+ };
188
197
  /** Ref to the editable DOM element. */
189
198
  editorRef: RefObject<HTMLDivElement | null>;
190
199
  /** Narrow editor controller for plugin actions. */
package/dist/index.js CHANGED
@@ -133,7 +133,7 @@ function BubbleMenuWidget({
133
133
  left: position.left,
134
134
  transform: position.placement === "above" ? "translateX(-50%) translateY(-100%)" : "translateX(-50%)",
135
135
  marginTop: position.placement === "above" ? -8 : 8,
136
- zIndex: 1100
136
+ zIndex: 9999
137
137
  },
138
138
  onMouseDown: (e) => e.preventDefault(),
139
139
  children: /* @__PURE__ */ jsx("div", { className: cls("inner"), children: items.map((item) => /* @__PURE__ */ jsx(item.render, { wrapSelection }, item.key)) })
@@ -1421,7 +1421,8 @@ var InkwellEditorClient = forwardRef(
1421
1421
  activePluginRef.current = activePlugin;
1422
1422
  const pluginPositionRef = useRef({
1423
1423
  top: 0,
1424
- left: 0
1424
+ left: 0,
1425
+ cursorRect: { top: 0, bottom: 0, left: 0 }
1425
1426
  });
1426
1427
  const forwardedKeyListenersRef = useRef(/* @__PURE__ */ new Map());
1427
1428
  const wrapperRef = useRef(null);
@@ -1532,10 +1533,14 @@ var InkwellEditorClient = forwardRef(
1532
1533
  ]
1533
1534
  );
1534
1535
  const getCursorPosition = useCallback(() => {
1536
+ const empty = {
1537
+ top: 0,
1538
+ left: 0,
1539
+ cursorRect: { top: 0, bottom: 0, left: 0 }
1540
+ };
1535
1541
  try {
1536
1542
  const domSelection = window.getSelection();
1537
- if (!domSelection || domSelection.rangeCount === 0)
1538
- return { top: 0, left: 0 };
1543
+ if (!domSelection || domSelection.rangeCount === 0) return empty;
1539
1544
  const range = domSelection.getRangeAt(0);
1540
1545
  let rect = range.getBoundingClientRect();
1541
1546
  if (rect.width === 0 && rect.height === 0 && domSelection.anchorNode) {
@@ -1543,14 +1548,22 @@ var InkwellEditorClient = forwardRef(
1543
1548
  if (node) rect = node.getBoundingClientRect();
1544
1549
  }
1545
1550
  const wrapperEl = wrapperRef.current;
1546
- if (!wrapperEl) return { top: 0, left: 0 };
1551
+ if (!wrapperEl) return empty;
1547
1552
  const wrapperRect = wrapperEl.getBoundingClientRect();
1553
+ const cursorTop = rect.top - wrapperRect.top;
1554
+ const cursorBottom = rect.bottom - wrapperRect.top;
1555
+ const cursorLeft = rect.left - wrapperRect.left;
1548
1556
  return {
1549
- top: rect.bottom - wrapperRect.top + 4,
1550
- left: rect.left - wrapperRect.left
1557
+ top: cursorBottom + 4,
1558
+ left: cursorLeft,
1559
+ cursorRect: {
1560
+ top: cursorTop,
1561
+ bottom: cursorBottom,
1562
+ left: cursorLeft
1563
+ }
1551
1564
  };
1552
1565
  } catch {
1553
- return { top: 0, left: 0 };
1566
+ return empty;
1554
1567
  }
1555
1568
  }, []);
1556
1569
  const wrapSelection = useCallback(
@@ -1818,7 +1831,11 @@ var InkwellEditorClient = forwardRef(
1818
1831
  query: activePlugin === plugin ? activePluginQuery : "",
1819
1832
  onSelect: handlePluginSelect,
1820
1833
  onDismiss: dismissPlugin,
1821
- position: pluginPositionRef.current,
1834
+ position: {
1835
+ top: pluginPositionRef.current.top,
1836
+ left: pluginPositionRef.current.left
1837
+ },
1838
+ cursorRect: pluginPositionRef.current.cursorRect,
1822
1839
  editorRef: editorElRef,
1823
1840
  editor: pluginEditor,
1824
1841
  wrapSelection,
@@ -2258,8 +2275,10 @@ function createCompletionsPlugin({
2258
2275
  var BASE = "inkwell-plugin-picker";
2259
2276
  var pluginPickerClass = {
2260
2277
  popup: `${BASE}-popup`,
2278
+ popupFlipped: `${BASE}-popup-flipped`,
2261
2279
  picker: `${BASE}`,
2262
2280
  search: `${BASE}-search`,
2281
+ list: `${BASE}-list`,
2263
2282
  item: `${BASE}-item`,
2264
2283
  itemActive: `${BASE}-item-active`,
2265
2284
  empty: `${BASE}-empty`,
@@ -2267,6 +2286,74 @@ var pluginPickerClass = {
2267
2286
  subtitle: `${BASE}-subtitle`,
2268
2287
  preview: `${BASE}-preview`
2269
2288
  };
2289
+ var POPUP_GAP = 4;
2290
+ var VIEWPORT_MARGIN = 8;
2291
+ var CURSOR_HEIGHT_FALLBACK = 20;
2292
+ function usePickerPlacement(position, cursorRect) {
2293
+ const [popupEl, setPopupEl] = useState(null);
2294
+ const [placement, setPlacement] = useState({ flippedAbove: false, leftOverride: null });
2295
+ const cursorTopWrapper = cursorRect?.top ?? Math.max(0, position.top - POPUP_GAP - CURSOR_HEIGHT_FALLBACK);
2296
+ const cursorBottomWrapper = cursorRect?.bottom ?? Math.max(0, position.top - POPUP_GAP);
2297
+ const cursorLeftWrapper = cursorRect?.left ?? position.left;
2298
+ useLayoutEffect(() => {
2299
+ if (!popupEl) return;
2300
+ const measure = () => {
2301
+ const offsetParent = popupEl.offsetParent;
2302
+ if (!(offsetParent instanceof HTMLElement)) return;
2303
+ const wrapperRect = offsetParent.getBoundingClientRect();
2304
+ const popupRect = popupEl.getBoundingClientRect();
2305
+ const popupHeight = popupRect.height;
2306
+ const popupWidth = popupRect.width;
2307
+ const cursorTopViewport = wrapperRect.top + cursorTopWrapper;
2308
+ const cursorBottomViewport = wrapperRect.top + cursorBottomWrapper;
2309
+ const cursorLeftViewport = wrapperRect.left + cursorLeftWrapper;
2310
+ const spaceBelowViewport = window.innerHeight - cursorBottomViewport - VIEWPORT_MARGIN;
2311
+ const spaceBelowWrapper = wrapperRect.bottom - cursorBottomViewport;
2312
+ const spaceBelow = Math.min(spaceBelowViewport, spaceBelowWrapper);
2313
+ const spaceAbove = cursorTopViewport - VIEWPORT_MARGIN;
2314
+ const needsFlip = popupHeight + POPUP_GAP > spaceBelow;
2315
+ const fitsAbove = popupHeight + POPUP_GAP <= spaceAbove;
2316
+ const flippedAbove = needsFlip && fitsAbove;
2317
+ const maxRightViewport = window.innerWidth - VIEWPORT_MARGIN;
2318
+ const popupRightIfDefault = cursorLeftViewport + popupWidth;
2319
+ const leftOverride = popupRightIfDefault > maxRightViewport ? Math.max(
2320
+ VIEWPORT_MARGIN - wrapperRect.left,
2321
+ maxRightViewport - popupWidth - wrapperRect.left
2322
+ ) : null;
2323
+ setPlacement(
2324
+ (prev) => prev.flippedAbove === flippedAbove && prev.leftOverride === leftOverride ? prev : { flippedAbove, leftOverride }
2325
+ );
2326
+ };
2327
+ measure();
2328
+ const ResizeObserverCtor = typeof window !== "undefined" ? window.ResizeObserver : void 0;
2329
+ const resizeObserver = ResizeObserverCtor ? new ResizeObserverCtor(() => measure()) : null;
2330
+ resizeObserver?.observe(popupEl);
2331
+ window.addEventListener("resize", measure);
2332
+ window.addEventListener("scroll", measure, true);
2333
+ return () => {
2334
+ resizeObserver?.disconnect();
2335
+ window.removeEventListener("resize", measure);
2336
+ window.removeEventListener("scroll", measure, true);
2337
+ };
2338
+ }, [popupEl, cursorTopWrapper, cursorBottomWrapper, cursorLeftWrapper]);
2339
+ const style = {
2340
+ position: "absolute",
2341
+ top: placement.flippedAbove ? cursorTopWrapper - POPUP_GAP : position.top,
2342
+ left: placement.leftOverride ?? position.left,
2343
+ transform: placement.flippedAbove ? "translateY(-100%)" : void 0,
2344
+ zIndex: 1001
2345
+ };
2346
+ const className = placement.flippedAbove ? `${pluginPickerClass.popup} ${pluginPickerClass.popupFlipped}` : pluginPickerClass.popup;
2347
+ return {
2348
+ setPopupEl,
2349
+ style,
2350
+ className,
2351
+ flippedAbove: placement.flippedAbove
2352
+ };
2353
+ }
2354
+ function usePluginPopupPlacement(position, cursorRect) {
2355
+ return usePickerPlacement(position, cursorRect);
2356
+ }
2270
2357
  function PluginMenuPrimitive({
2271
2358
  items,
2272
2359
  search,
@@ -2278,6 +2365,7 @@ function PluginMenuPrimitive({
2278
2365
  onSelect,
2279
2366
  onDismiss,
2280
2367
  position,
2368
+ cursorRect,
2281
2369
  subscribeForwardedKey
2282
2370
  }) {
2283
2371
  const [selectedIndex, setSelectedIndex] = useState(0);
@@ -2422,16 +2510,13 @@ function PluginMenuPrimitive({
2422
2510
  updateSelectedIndex
2423
2511
  ]
2424
2512
  );
2513
+ const placement = usePickerPlacement(position, cursorRect);
2425
2514
  return /* @__PURE__ */ jsx(
2426
2515
  "div",
2427
2516
  {
2428
- className: pluginPickerClass.popup,
2429
- style: {
2430
- position: "absolute",
2431
- top: position.top,
2432
- left: position.left,
2433
- zIndex: 1001
2434
- },
2517
+ ref: placement.setPopupEl,
2518
+ className: placement.className,
2519
+ style: placement.style,
2435
2520
  onMouseDown: (event) => event.preventDefault(),
2436
2521
  children: /* @__PURE__ */ jsxs(
2437
2522
  "div",
@@ -2445,7 +2530,7 @@ function PluginMenuPrimitive({
2445
2530
  "aria-activedescendant": results.length > 0 ? activeOptionId : void 0,
2446
2531
  children: [
2447
2532
  /* @__PURE__ */ jsx("div", { className: pluginPickerClass.search, "aria-label": placeholder, children: query || placeholder }),
2448
- results.length === 0 ? /* @__PURE__ */ jsx("div", { className: pluginPickerClass.empty, role: "status", children: emptyMessage }) : /* @__PURE__ */ jsx("div", { id: listboxId, role: "listbox", children: renderedResults })
2533
+ results.length === 0 ? /* @__PURE__ */ jsx("div", { className: pluginPickerClass.empty, role: "status", children: emptyMessage }) : /* @__PURE__ */ jsx("div", { id: listboxId, role: "listbox", className: pluginPickerClass.list, children: renderedResults })
2449
2534
  ]
2450
2535
  }
2451
2536
  )
@@ -2607,6 +2692,7 @@ var SlashCommandMenuInner = forwardRef(function SlashCommandMenuInner2({
2607
2692
  onExecute,
2608
2693
  onDismiss,
2609
2694
  position,
2695
+ cursorRect,
2610
2696
  getEditor
2611
2697
  }, ref) {
2612
2698
  const [mode, setMode] = useState("commands");
@@ -2770,17 +2856,14 @@ var SlashCommandMenuInner = forwardRef(function SlashCommandMenuInner2({
2770
2856
  selectedIndex
2771
2857
  ]
2772
2858
  );
2859
+ const placement = usePluginPopupPlacement(position, cursorRect);
2773
2860
  if (mode === "ready" && selectedCommand) {
2774
2861
  return /* @__PURE__ */ jsx(
2775
2862
  "div",
2776
2863
  {
2777
- className: pluginPickerClass.popup,
2778
- style: {
2779
- position: "absolute",
2780
- top: position.top,
2781
- left: position.left,
2782
- zIndex: 1001
2783
- },
2864
+ ref: placement.setPopupEl,
2865
+ className: placement.className,
2866
+ style: placement.style,
2784
2867
  children: /* @__PURE__ */ jsx("div", { className: pluginPickerClass.picker, children: /* @__PURE__ */ jsx("div", { className: "inkwell-plugin-slash-commands-execute", children: "Enter to execute \xB7 Esc to cancel" }) })
2785
2868
  }
2786
2869
  );
@@ -2788,13 +2871,9 @@ var SlashCommandMenuInner = forwardRef(function SlashCommandMenuInner2({
2788
2871
  return /* @__PURE__ */ jsx(
2789
2872
  "div",
2790
2873
  {
2791
- className: pluginPickerClass.popup,
2792
- style: {
2793
- position: "absolute",
2794
- top: position.top,
2795
- left: position.left,
2796
- zIndex: 1001
2797
- },
2874
+ ref: placement.setPopupEl,
2875
+ className: placement.className,
2876
+ style: placement.style,
2798
2877
  children: /* @__PURE__ */ jsxs("div", { className: pluginPickerClass.picker, children: [
2799
2878
  /* @__PURE__ */ jsx("div", { className: pluginPickerClass.search, children: mode === "commands" ? `/${query}` : `${selectedCommand ? `/${selectedCommand.name} ` : ""}${query}` }),
2800
2879
  loadingArgs && items.length === 0 ? /* @__PURE__ */ jsx("div", { className: pluginPickerClass.empty, children: "Loading..." }) : items.length === 0 ? /* @__PURE__ */ jsx("div", { className: pluginPickerClass.empty, children: emptyMessage }) : /* @__PURE__ */ jsx(
@@ -2803,6 +2882,7 @@ var SlashCommandMenuInner = forwardRef(function SlashCommandMenuInner2({
2803
2882
  id: listboxId,
2804
2883
  role: "listbox",
2805
2884
  "aria-activedescendant": activeOptionId,
2885
+ className: pluginPickerClass.list,
2806
2886
  children: items.map((item, index) => {
2807
2887
  const active = index === selectedIndex;
2808
2888
  return /* @__PURE__ */ jsxs(
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@railway/inkwell",
3
- "version": "1.0.0",
3
+ "version": "1.1.1",
4
4
  "description": "Inkwell is a Markdown editor and renderer for React with an extensible plugin system.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.cjs",
@@ -63,6 +63,7 @@
63
63
  "@testing-library/jest-dom": "^6.9.1",
64
64
  "@testing-library/react": "^16.3.2",
65
65
  "@types/mdast": "^4.0.4",
66
+ "@types/node": "^24.0.0",
66
67
  "@types/react": "^19.0.0",
67
68
  "@types/react-dom": "^19.0.0",
68
69
  "@vitejs/plugin-react": "^6.0.1",
package/src/styles.css CHANGED
@@ -72,21 +72,34 @@
72
72
  .inkwell-editor-wrapper {
73
73
  position: relative;
74
74
  }
75
- .inkwell-editor {
76
- min-height: 200px;
75
+
76
+ /* Visual-chrome defaults (padding, border, background, type) are wrapped in
77
+ `:where()` so they carry 0,0,0 specificity. Any single-class consumer rule
78
+ on `.inkwell-editor` (Tailwind utilities, `classNames.editor`, etc.) wins
79
+ automatically — no `!important` or selector gymnastics needed.
80
+
81
+ Container size (min-height, max-height, height) is deliberately NOT
82
+ defaulted here. Inkwell sits inside the consumer's layout and the right
83
+ size is a consumer decision: a full-page editor wants `min-height: 60vh`,
84
+ a chat composer wants `min-height: 0`, a panel just wants to fill its
85
+ container. Set it on the editor via `styles.editor`, `classNames.editor`,
86
+ or your own CSS. */
87
+ :where(.inkwell-editor) {
77
88
  padding: 1rem 1.25rem;
78
- outline: none;
79
89
  border: 1px solid var(--inkwell-border);
80
90
  border-radius: var(--inkwell-radius);
81
91
  background: var(--inkwell-bg);
82
- color: var(--inkwell-text);
83
92
  line-height: 1.6;
84
93
  font-size: 0.95rem;
85
94
  transition: border-color 0.15s ease;
86
95
  }
87
- .inkwell-editor:focus-within {
96
+ :where(.inkwell-editor:focus-within) {
88
97
  border-color: var(--inkwell-border-strong);
89
98
  }
99
+ .inkwell-editor {
100
+ outline: none;
101
+ color: var(--inkwell-text);
102
+ }
90
103
 
91
104
  .inkwell-editor p {
92
105
  margin: 0;
@@ -183,7 +196,9 @@
183
196
  color: hsl(0, 75%, 55%);
184
197
  font-weight: 500;
185
198
  }
186
- .inkwell-editor-wrapper.inkwell-editor-has-character-limit .inkwell-editor {
199
+ :where(
200
+ .inkwell-editor-wrapper.inkwell-editor-has-character-limit .inkwell-editor
201
+ ) {
187
202
  padding-right: 5.25rem;
188
203
  padding-bottom: 2.25rem;
189
204
  }
@@ -226,7 +241,7 @@
226
241
 
227
242
  .inkwell-plugin-bubble-menu-container {
228
243
  position: absolute;
229
- z-index: 1100;
244
+ z-index: 9999;
230
245
  }
231
246
  .inkwell-plugin-bubble-menu-inner {
232
247
  display: flex;
@@ -303,6 +318,28 @@
303
318
  .inkwell-plugin-picker-search::placeholder {
304
319
  color: var(--inkwell-text-dim);
305
320
  }
321
+ .inkwell-plugin-picker-list {
322
+ max-height: 240px;
323
+ overflow-y: auto;
324
+ overscroll-behavior: contain;
325
+ scrollbar-width: thin;
326
+ scrollbar-color: var(--inkwell-border-strong) transparent;
327
+ }
328
+ .inkwell-plugin-picker-list::-webkit-scrollbar {
329
+ width: 8px;
330
+ }
331
+ .inkwell-plugin-picker-list::-webkit-scrollbar-track {
332
+ background: transparent;
333
+ }
334
+ .inkwell-plugin-picker-list::-webkit-scrollbar-thumb {
335
+ background-color: var(--inkwell-border-strong);
336
+ border-radius: 4px;
337
+ border: 2px solid transparent;
338
+ background-clip: padding-box;
339
+ }
340
+ .inkwell-plugin-picker-list::-webkit-scrollbar-thumb:hover {
341
+ background-color: var(--inkwell-text-dim);
342
+ }
306
343
  .inkwell-plugin-picker-item {
307
344
  padding: 7px 10px;
308
345
  cursor: pointer;
@@ -348,9 +385,13 @@
348
385
 
349
386
  /* ── Renderer ────────────────────────────────────────────────────── */
350
387
 
351
- .inkwell-renderer {
388
+ /* Layout defaults on the renderer follow the same low-specificity pattern
389
+ as the editor — a single-class consumer rule wins automatically. */
390
+ :where(.inkwell-renderer) {
352
391
  line-height: 1.65;
353
392
  font-size: 0.95rem;
393
+ }
394
+ .inkwell-renderer {
354
395
  color: var(--inkwell-text);
355
396
  }
356
397
  .inkwell-renderer :first-child {