@railway/inkwell 1.1.0 → 1.2.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.
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
@@ -1377,6 +1377,7 @@ var InkwellEditorClient = react.forwardRef(
1377
1377
  );
1378
1378
  const overLimit = characterLimit !== void 0 && characterCount > characterLimit;
1379
1379
  const hasCharacterLimit = characterLimit !== void 0;
1380
+ const showCharacterCount = characterLimit !== void 0 && characterCount >= characterLimit * 0.8;
1380
1381
  const getEditorState = react.useCallback(() => {
1381
1382
  const content2 = serializeContent();
1382
1383
  return {
@@ -1436,7 +1437,8 @@ var InkwellEditorClient = react.forwardRef(
1436
1437
  activePluginRef.current = activePlugin;
1437
1438
  const pluginPositionRef = react.useRef({
1438
1439
  top: 0,
1439
- left: 0
1440
+ left: 0,
1441
+ cursorRect: { top: 0, bottom: 0, left: 0 }
1440
1442
  });
1441
1443
  const forwardedKeyListenersRef = react.useRef(/* @__PURE__ */ new Map());
1442
1444
  const wrapperRef = react.useRef(null);
@@ -1547,10 +1549,14 @@ var InkwellEditorClient = react.forwardRef(
1547
1549
  ]
1548
1550
  );
1549
1551
  const getCursorPosition = react.useCallback(() => {
1552
+ const empty = {
1553
+ top: 0,
1554
+ left: 0,
1555
+ cursorRect: { top: 0, bottom: 0, left: 0 }
1556
+ };
1550
1557
  try {
1551
1558
  const domSelection = window.getSelection();
1552
- if (!domSelection || domSelection.rangeCount === 0)
1553
- return { top: 0, left: 0 };
1559
+ if (!domSelection || domSelection.rangeCount === 0) return empty;
1554
1560
  const range = domSelection.getRangeAt(0);
1555
1561
  let rect = range.getBoundingClientRect();
1556
1562
  if (rect.width === 0 && rect.height === 0 && domSelection.anchorNode) {
@@ -1558,14 +1564,22 @@ var InkwellEditorClient = react.forwardRef(
1558
1564
  if (node) rect = node.getBoundingClientRect();
1559
1565
  }
1560
1566
  const wrapperEl = wrapperRef.current;
1561
- if (!wrapperEl) return { top: 0, left: 0 };
1567
+ if (!wrapperEl) return empty;
1562
1568
  const wrapperRect = wrapperEl.getBoundingClientRect();
1569
+ const cursorTop = rect.top - wrapperRect.top;
1570
+ const cursorBottom = rect.bottom - wrapperRect.top;
1571
+ const cursorLeft = rect.left - wrapperRect.left;
1563
1572
  return {
1564
- top: rect.bottom - wrapperRect.top + 4,
1565
- left: rect.left - wrapperRect.left
1573
+ top: cursorBottom + 4,
1574
+ left: cursorLeft,
1575
+ cursorRect: {
1576
+ top: cursorTop,
1577
+ bottom: cursorBottom,
1578
+ left: cursorLeft
1579
+ }
1566
1580
  };
1567
1581
  } catch {
1568
- return { top: 0, left: 0 };
1582
+ return empty;
1569
1583
  }
1570
1584
  }, []);
1571
1585
  const wrapSelection = react.useCallback(
@@ -1833,7 +1847,11 @@ var InkwellEditorClient = react.forwardRef(
1833
1847
  query: activePlugin === plugin ? activePluginQuery : "",
1834
1848
  onSelect: handlePluginSelect,
1835
1849
  onDismiss: dismissPlugin,
1836
- position: pluginPositionRef.current,
1850
+ position: {
1851
+ top: pluginPositionRef.current.top,
1852
+ left: pluginPositionRef.current.left
1853
+ },
1854
+ cursorRect: pluginPositionRef.current.cursorRect,
1837
1855
  editorRef: editorElRef,
1838
1856
  editor: pluginEditor,
1839
1857
  wrapSelection,
@@ -2009,7 +2027,7 @@ var InkwellEditorClient = react.forwardRef(
2009
2027
  className: `inkwell-editor-wrapper${hasCharacterLimit ? " inkwell-editor-has-character-limit" : ""}${overLimit ? " inkwell-editor-over-limit" : ""}${className ? ` ${className}` : ""}${classNames?.root ? ` ${classNames.root}` : ""}`,
2010
2028
  style: styles?.root,
2011
2029
  children: [
2012
- hasCharacterLimit && /* @__PURE__ */ jsxRuntime.jsx(
2030
+ showCharacterCount && /* @__PURE__ */ jsxRuntime.jsx(
2013
2031
  CharacterCount,
2014
2032
  {
2015
2033
  count: characterCount,
@@ -2168,16 +2186,41 @@ var insertUploadedAttachment = (file, options) => {
2168
2186
  options.onError?.(err, file);
2169
2187
  });
2170
2188
  };
2171
- function createAttachmentsPlugin(options) {
2189
+ function routeFiles(editor, files, options) {
2172
2190
  const { accept } = options;
2191
+ const matching = accept ? files.filter((f) => mimeMatches(f.type, accept)) : files;
2192
+ const handled = matching.filter(
2193
+ (f) => isImageFile(f) || options.onAttachmentAdd !== void 0
2194
+ );
2195
+ for (const file of handled) {
2196
+ if (isImageFile(file)) {
2197
+ insertUploadedImage(editor, file, options);
2198
+ } else {
2199
+ insertUploadedAttachment(file, options);
2200
+ }
2201
+ }
2202
+ const skipped = files.filter((f) => !handled.includes(f));
2203
+ return { handled, skipped };
2204
+ }
2205
+ function createAttachmentsPlugin(options) {
2173
2206
  return {
2174
2207
  name: "attachments",
2208
+ setup(editor) {
2209
+ if (!options.ref) return;
2210
+ const writableRef = options.ref;
2211
+ writableRef.current = {
2212
+ upload: (files) => {
2213
+ if (files.length === 0) return;
2214
+ routeFiles(editor, files, options);
2215
+ }
2216
+ };
2217
+ return () => {
2218
+ writableRef.current = null;
2219
+ };
2220
+ },
2175
2221
  onInsertData(data, { editor, insertData }) {
2176
2222
  const files = extractFiles(data);
2177
- const matching = accept ? files.filter((f) => mimeMatches(f.type, accept)) : files;
2178
- const handled = matching.filter(
2179
- (f) => isImageFile(f) || options.onAttachmentAdd !== void 0
2180
- );
2223
+ const { handled, skipped } = routeFiles(editor, files, options);
2181
2224
  if (handled.length === 0) {
2182
2225
  const htmlImages = extractHtmlImages(data);
2183
2226
  if (htmlImages.length === 0) return false;
@@ -2186,16 +2229,8 @@ function createAttachmentsPlugin(options) {
2186
2229
  }
2187
2230
  return true;
2188
2231
  }
2189
- const unhandled = files.filter((f) => !handled.includes(f));
2190
- if (unhandled.length > 0) {
2191
- insertData(filesOnlyDataTransfer(unhandled));
2192
- }
2193
- for (const file of handled) {
2194
- if (isImageFile(file)) {
2195
- insertUploadedImage(editor, file, options);
2196
- } else {
2197
- insertUploadedAttachment(file, options);
2198
- }
2232
+ if (skipped.length > 0) {
2233
+ insertData(filesOnlyDataTransfer(skipped));
2199
2234
  }
2200
2235
  return true;
2201
2236
  }
@@ -2273,8 +2308,10 @@ function createCompletionsPlugin({
2273
2308
  var BASE = "inkwell-plugin-picker";
2274
2309
  var pluginPickerClass = {
2275
2310
  popup: `${BASE}-popup`,
2311
+ popupFlipped: `${BASE}-popup-flipped`,
2276
2312
  picker: `${BASE}`,
2277
2313
  search: `${BASE}-search`,
2314
+ list: `${BASE}-list`,
2278
2315
  item: `${BASE}-item`,
2279
2316
  itemActive: `${BASE}-item-active`,
2280
2317
  empty: `${BASE}-empty`,
@@ -2282,6 +2319,74 @@ var pluginPickerClass = {
2282
2319
  subtitle: `${BASE}-subtitle`,
2283
2320
  preview: `${BASE}-preview`
2284
2321
  };
2322
+ var POPUP_GAP = 4;
2323
+ var VIEWPORT_MARGIN = 8;
2324
+ var CURSOR_HEIGHT_FALLBACK = 20;
2325
+ function usePickerPlacement(position, cursorRect) {
2326
+ const [popupEl, setPopupEl] = react.useState(null);
2327
+ const [placement, setPlacement] = react.useState({ flippedAbove: false, leftOverride: null });
2328
+ const cursorTopWrapper = cursorRect?.top ?? Math.max(0, position.top - POPUP_GAP - CURSOR_HEIGHT_FALLBACK);
2329
+ const cursorBottomWrapper = cursorRect?.bottom ?? Math.max(0, position.top - POPUP_GAP);
2330
+ const cursorLeftWrapper = cursorRect?.left ?? position.left;
2331
+ react.useLayoutEffect(() => {
2332
+ if (!popupEl) return;
2333
+ const measure = () => {
2334
+ const offsetParent = popupEl.offsetParent;
2335
+ if (!(offsetParent instanceof HTMLElement)) return;
2336
+ const wrapperRect = offsetParent.getBoundingClientRect();
2337
+ const popupRect = popupEl.getBoundingClientRect();
2338
+ const popupHeight = popupRect.height;
2339
+ const popupWidth = popupRect.width;
2340
+ const cursorTopViewport = wrapperRect.top + cursorTopWrapper;
2341
+ const cursorBottomViewport = wrapperRect.top + cursorBottomWrapper;
2342
+ const cursorLeftViewport = wrapperRect.left + cursorLeftWrapper;
2343
+ const spaceBelowViewport = window.innerHeight - cursorBottomViewport - VIEWPORT_MARGIN;
2344
+ const spaceBelowWrapper = wrapperRect.bottom - cursorBottomViewport;
2345
+ const spaceBelow = Math.min(spaceBelowViewport, spaceBelowWrapper);
2346
+ const spaceAbove = cursorTopViewport - VIEWPORT_MARGIN;
2347
+ const needsFlip = popupHeight + POPUP_GAP > spaceBelow;
2348
+ const fitsAbove = popupHeight + POPUP_GAP <= spaceAbove;
2349
+ const flippedAbove = needsFlip && fitsAbove;
2350
+ const maxRightViewport = window.innerWidth - VIEWPORT_MARGIN;
2351
+ const popupRightIfDefault = cursorLeftViewport + popupWidth;
2352
+ const leftOverride = popupRightIfDefault > maxRightViewport ? Math.max(
2353
+ VIEWPORT_MARGIN - wrapperRect.left,
2354
+ maxRightViewport - popupWidth - wrapperRect.left
2355
+ ) : null;
2356
+ setPlacement(
2357
+ (prev) => prev.flippedAbove === flippedAbove && prev.leftOverride === leftOverride ? prev : { flippedAbove, leftOverride }
2358
+ );
2359
+ };
2360
+ measure();
2361
+ const ResizeObserverCtor = typeof window !== "undefined" ? window.ResizeObserver : void 0;
2362
+ const resizeObserver = ResizeObserverCtor ? new ResizeObserverCtor(() => measure()) : null;
2363
+ resizeObserver?.observe(popupEl);
2364
+ window.addEventListener("resize", measure);
2365
+ window.addEventListener("scroll", measure, true);
2366
+ return () => {
2367
+ resizeObserver?.disconnect();
2368
+ window.removeEventListener("resize", measure);
2369
+ window.removeEventListener("scroll", measure, true);
2370
+ };
2371
+ }, [popupEl, cursorTopWrapper, cursorBottomWrapper, cursorLeftWrapper]);
2372
+ const style = {
2373
+ position: "absolute",
2374
+ top: placement.flippedAbove ? cursorTopWrapper - POPUP_GAP : position.top,
2375
+ left: placement.leftOverride ?? position.left,
2376
+ transform: placement.flippedAbove ? "translateY(-100%)" : void 0,
2377
+ zIndex: 1001
2378
+ };
2379
+ const className = placement.flippedAbove ? `${pluginPickerClass.popup} ${pluginPickerClass.popupFlipped}` : pluginPickerClass.popup;
2380
+ return {
2381
+ setPopupEl,
2382
+ style,
2383
+ className,
2384
+ flippedAbove: placement.flippedAbove
2385
+ };
2386
+ }
2387
+ function usePluginPopupPlacement(position, cursorRect) {
2388
+ return usePickerPlacement(position, cursorRect);
2389
+ }
2285
2390
  function PluginMenuPrimitive({
2286
2391
  items,
2287
2392
  search,
@@ -2293,6 +2398,7 @@ function PluginMenuPrimitive({
2293
2398
  onSelect,
2294
2399
  onDismiss,
2295
2400
  position,
2401
+ cursorRect,
2296
2402
  subscribeForwardedKey
2297
2403
  }) {
2298
2404
  const [selectedIndex, setSelectedIndex] = react.useState(0);
@@ -2437,16 +2543,13 @@ function PluginMenuPrimitive({
2437
2543
  updateSelectedIndex
2438
2544
  ]
2439
2545
  );
2546
+ const placement = usePickerPlacement(position, cursorRect);
2440
2547
  return /* @__PURE__ */ jsxRuntime.jsx(
2441
2548
  "div",
2442
2549
  {
2443
- className: pluginPickerClass.popup,
2444
- style: {
2445
- position: "absolute",
2446
- top: position.top,
2447
- left: position.left,
2448
- zIndex: 1001
2449
- },
2550
+ ref: placement.setPopupEl,
2551
+ className: placement.className,
2552
+ style: placement.style,
2450
2553
  onMouseDown: (event) => event.preventDefault(),
2451
2554
  children: /* @__PURE__ */ jsxRuntime.jsxs(
2452
2555
  "div",
@@ -2460,7 +2563,7 @@ function PluginMenuPrimitive({
2460
2563
  "aria-activedescendant": results.length > 0 ? activeOptionId : void 0,
2461
2564
  children: [
2462
2565
  /* @__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 })
2566
+ 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
2567
  ]
2465
2568
  }
2466
2569
  )
@@ -2622,6 +2725,7 @@ var SlashCommandMenuInner = react.forwardRef(function SlashCommandMenuInner2({
2622
2725
  onExecute,
2623
2726
  onDismiss,
2624
2727
  position,
2728
+ cursorRect,
2625
2729
  getEditor
2626
2730
  }, ref) {
2627
2731
  const [mode, setMode] = react.useState("commands");
@@ -2785,17 +2889,14 @@ var SlashCommandMenuInner = react.forwardRef(function SlashCommandMenuInner2({
2785
2889
  selectedIndex
2786
2890
  ]
2787
2891
  );
2892
+ const placement = usePluginPopupPlacement(position, cursorRect);
2788
2893
  if (mode === "ready" && selectedCommand) {
2789
2894
  return /* @__PURE__ */ jsxRuntime.jsx(
2790
2895
  "div",
2791
2896
  {
2792
- className: pluginPickerClass.popup,
2793
- style: {
2794
- position: "absolute",
2795
- top: position.top,
2796
- left: position.left,
2797
- zIndex: 1001
2798
- },
2897
+ ref: placement.setPopupEl,
2898
+ className: placement.className,
2899
+ style: placement.style,
2799
2900
  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
2901
  }
2801
2902
  );
@@ -2803,13 +2904,9 @@ var SlashCommandMenuInner = react.forwardRef(function SlashCommandMenuInner2({
2803
2904
  return /* @__PURE__ */ jsxRuntime.jsx(
2804
2905
  "div",
2805
2906
  {
2806
- className: pluginPickerClass.popup,
2807
- style: {
2808
- position: "absolute",
2809
- top: position.top,
2810
- left: position.left,
2811
- zIndex: 1001
2812
- },
2907
+ ref: placement.setPopupEl,
2908
+ className: placement.className,
2909
+ style: placement.style,
2813
2910
  children: /* @__PURE__ */ jsxRuntime.jsxs("div", { className: pluginPickerClass.picker, children: [
2814
2911
  /* @__PURE__ */ jsxRuntime.jsx("div", { className: pluginPickerClass.search, children: mode === "commands" ? `/${query}` : `${selectedCommand ? `/${selectedCommand.name} ` : ""}${query}` }),
2815
2912
  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 +2915,7 @@ var SlashCommandMenuInner = react.forwardRef(function SlashCommandMenuInner2({
2818
2915
  id: listboxId,
2819
2916
  role: "listbox",
2820
2917
  "aria-activedescendant": activeOptionId,
2918
+ className: pluginPickerClass.list,
2821
2919
  children: items.map((item, index) => {
2822
2920
  const active = index === selectedIndex;
2823
2921
  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. */
@@ -271,6 +280,9 @@ interface Attachment {
271
280
  */
272
281
  [key: string]: unknown;
273
282
  }
283
+ interface AttachmentsHandle {
284
+ upload: (files: File[]) => void;
285
+ }
274
286
  interface AttachmentsPluginOptions {
275
287
  /**
276
288
  * Upload a single file and resolve to the public URL, or an object
@@ -283,6 +295,8 @@ interface AttachmentsPluginOptions {
283
295
  * (`image/*`). Files that don't match pass through untouched.
284
296
  */
285
297
  accept?: string;
298
+ /** Populated on editor mount, nulled on unmount. */
299
+ ref?: RefObject<AttachmentsHandle | null>;
286
300
  /**
287
301
  * Placeholder alt text shown on the inserted image element while an
288
302
  * image upload is in flight. Defaults to `"Uploading…"`.
@@ -475,4 +489,4 @@ declare function InkwellRenderer({ content, className, components, rehypePlugins
475
489
  */
476
490
  declare function parseMarkdown(content: string, options?: ParseMarkdownOptions): ReactNode;
477
491
 
478
- export { type Attachment, type AttachmentUploadResult, type AttachmentsPluginOptions, type BubbleMenuItem, type BubbleMenuItemProps, type BubbleMenuOptions, type CompletionsPluginOptions, type EmojiItem, type EmojiPluginOptions, type InkwellComponents, InkwellEditor, type InkwellEditorClassNames, type InkwellEditorFocusOptions, type InkwellEditorHandle, type InkwellEditorProps, type InkwellEditorState, type InkwellEditorStyles, type InkwellFeatures, type InkwellPlugin, type InkwellPluginActivation, type InkwellPluginEditor, type InkwellPluginPlaceholder, InkwellRenderer, type InkwellRendererProps, type MentionItem, type MentionRenderer, type MentionsPluginOptions, type ParseMarkdownOptions, type PluginInsertDataContext, type PluginKeyDownContext, type PluginRenderProps, type RehypePluginConfig, type SlashCommandArg, type SlashCommandChoice, type SlashCommandExecution, type SlashCommandItem, type SlashCommandsPluginOptions, type Snippet, type SnippetsPluginOptions, type SubscribeForwardedKey, createAttachmentsPlugin, createBubbleMenuPlugin, createCompletionsPlugin, createEmojiPlugin, createMentionsPlugin, createSlashCommandsPlugin, createSnippetsPlugin, defaultBubbleMenuItems, defaultEmojis, htmlToMarkdown, parseMarkdown };
492
+ export { type Attachment, type AttachmentUploadResult, type AttachmentsHandle, type AttachmentsPluginOptions, type BubbleMenuItem, type BubbleMenuItemProps, type BubbleMenuOptions, type CompletionsPluginOptions, type EmojiItem, type EmojiPluginOptions, type InkwellComponents, InkwellEditor, type InkwellEditorClassNames, type InkwellEditorFocusOptions, type InkwellEditorHandle, type InkwellEditorProps, type InkwellEditorState, type InkwellEditorStyles, type InkwellFeatures, type InkwellPlugin, type InkwellPluginActivation, type InkwellPluginEditor, type InkwellPluginPlaceholder, InkwellRenderer, type InkwellRendererProps, type MentionItem, type MentionRenderer, type MentionsPluginOptions, type ParseMarkdownOptions, type PluginInsertDataContext, type PluginKeyDownContext, type PluginRenderProps, type RehypePluginConfig, type SlashCommandArg, type SlashCommandChoice, type SlashCommandExecution, type SlashCommandItem, type SlashCommandsPluginOptions, type Snippet, type SnippetsPluginOptions, type SubscribeForwardedKey, createAttachmentsPlugin, createBubbleMenuPlugin, createCompletionsPlugin, createEmojiPlugin, createMentionsPlugin, createSlashCommandsPlugin, createSnippetsPlugin, defaultBubbleMenuItems, defaultEmojis, htmlToMarkdown, parseMarkdown };
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. */
@@ -271,6 +280,9 @@ interface Attachment {
271
280
  */
272
281
  [key: string]: unknown;
273
282
  }
283
+ interface AttachmentsHandle {
284
+ upload: (files: File[]) => void;
285
+ }
274
286
  interface AttachmentsPluginOptions {
275
287
  /**
276
288
  * Upload a single file and resolve to the public URL, or an object
@@ -283,6 +295,8 @@ interface AttachmentsPluginOptions {
283
295
  * (`image/*`). Files that don't match pass through untouched.
284
296
  */
285
297
  accept?: string;
298
+ /** Populated on editor mount, nulled on unmount. */
299
+ ref?: RefObject<AttachmentsHandle | null>;
286
300
  /**
287
301
  * Placeholder alt text shown on the inserted image element while an
288
302
  * image upload is in flight. Defaults to `"Uploading…"`.
@@ -475,4 +489,4 @@ declare function InkwellRenderer({ content, className, components, rehypePlugins
475
489
  */
476
490
  declare function parseMarkdown(content: string, options?: ParseMarkdownOptions): ReactNode;
477
491
 
478
- export { type Attachment, type AttachmentUploadResult, type AttachmentsPluginOptions, type BubbleMenuItem, type BubbleMenuItemProps, type BubbleMenuOptions, type CompletionsPluginOptions, type EmojiItem, type EmojiPluginOptions, type InkwellComponents, InkwellEditor, type InkwellEditorClassNames, type InkwellEditorFocusOptions, type InkwellEditorHandle, type InkwellEditorProps, type InkwellEditorState, type InkwellEditorStyles, type InkwellFeatures, type InkwellPlugin, type InkwellPluginActivation, type InkwellPluginEditor, type InkwellPluginPlaceholder, InkwellRenderer, type InkwellRendererProps, type MentionItem, type MentionRenderer, type MentionsPluginOptions, type ParseMarkdownOptions, type PluginInsertDataContext, type PluginKeyDownContext, type PluginRenderProps, type RehypePluginConfig, type SlashCommandArg, type SlashCommandChoice, type SlashCommandExecution, type SlashCommandItem, type SlashCommandsPluginOptions, type Snippet, type SnippetsPluginOptions, type SubscribeForwardedKey, createAttachmentsPlugin, createBubbleMenuPlugin, createCompletionsPlugin, createEmojiPlugin, createMentionsPlugin, createSlashCommandsPlugin, createSnippetsPlugin, defaultBubbleMenuItems, defaultEmojis, htmlToMarkdown, parseMarkdown };
492
+ export { type Attachment, type AttachmentUploadResult, type AttachmentsHandle, type AttachmentsPluginOptions, type BubbleMenuItem, type BubbleMenuItemProps, type BubbleMenuOptions, type CompletionsPluginOptions, type EmojiItem, type EmojiPluginOptions, type InkwellComponents, InkwellEditor, type InkwellEditorClassNames, type InkwellEditorFocusOptions, type InkwellEditorHandle, type InkwellEditorProps, type InkwellEditorState, type InkwellEditorStyles, type InkwellFeatures, type InkwellPlugin, type InkwellPluginActivation, type InkwellPluginEditor, type InkwellPluginPlaceholder, InkwellRenderer, type InkwellRendererProps, type MentionItem, type MentionRenderer, type MentionsPluginOptions, type ParseMarkdownOptions, type PluginInsertDataContext, type PluginKeyDownContext, type PluginRenderProps, type RehypePluginConfig, type SlashCommandArg, type SlashCommandChoice, type SlashCommandExecution, type SlashCommandItem, type SlashCommandsPluginOptions, type Snippet, type SnippetsPluginOptions, type SubscribeForwardedKey, createAttachmentsPlugin, createBubbleMenuPlugin, createCompletionsPlugin, createEmojiPlugin, createMentionsPlugin, createSlashCommandsPlugin, createSnippetsPlugin, defaultBubbleMenuItems, defaultEmojis, htmlToMarkdown, parseMarkdown };
package/dist/index.js CHANGED
@@ -1362,6 +1362,7 @@ var InkwellEditorClient = forwardRef(
1362
1362
  );
1363
1363
  const overLimit = characterLimit !== void 0 && characterCount > characterLimit;
1364
1364
  const hasCharacterLimit = characterLimit !== void 0;
1365
+ const showCharacterCount = characterLimit !== void 0 && characterCount >= characterLimit * 0.8;
1365
1366
  const getEditorState = useCallback(() => {
1366
1367
  const content2 = serializeContent();
1367
1368
  return {
@@ -1421,7 +1422,8 @@ var InkwellEditorClient = forwardRef(
1421
1422
  activePluginRef.current = activePlugin;
1422
1423
  const pluginPositionRef = useRef({
1423
1424
  top: 0,
1424
- left: 0
1425
+ left: 0,
1426
+ cursorRect: { top: 0, bottom: 0, left: 0 }
1425
1427
  });
1426
1428
  const forwardedKeyListenersRef = useRef(/* @__PURE__ */ new Map());
1427
1429
  const wrapperRef = useRef(null);
@@ -1532,10 +1534,14 @@ var InkwellEditorClient = forwardRef(
1532
1534
  ]
1533
1535
  );
1534
1536
  const getCursorPosition = useCallback(() => {
1537
+ const empty = {
1538
+ top: 0,
1539
+ left: 0,
1540
+ cursorRect: { top: 0, bottom: 0, left: 0 }
1541
+ };
1535
1542
  try {
1536
1543
  const domSelection = window.getSelection();
1537
- if (!domSelection || domSelection.rangeCount === 0)
1538
- return { top: 0, left: 0 };
1544
+ if (!domSelection || domSelection.rangeCount === 0) return empty;
1539
1545
  const range = domSelection.getRangeAt(0);
1540
1546
  let rect = range.getBoundingClientRect();
1541
1547
  if (rect.width === 0 && rect.height === 0 && domSelection.anchorNode) {
@@ -1543,14 +1549,22 @@ var InkwellEditorClient = forwardRef(
1543
1549
  if (node) rect = node.getBoundingClientRect();
1544
1550
  }
1545
1551
  const wrapperEl = wrapperRef.current;
1546
- if (!wrapperEl) return { top: 0, left: 0 };
1552
+ if (!wrapperEl) return empty;
1547
1553
  const wrapperRect = wrapperEl.getBoundingClientRect();
1554
+ const cursorTop = rect.top - wrapperRect.top;
1555
+ const cursorBottom = rect.bottom - wrapperRect.top;
1556
+ const cursorLeft = rect.left - wrapperRect.left;
1548
1557
  return {
1549
- top: rect.bottom - wrapperRect.top + 4,
1550
- left: rect.left - wrapperRect.left
1558
+ top: cursorBottom + 4,
1559
+ left: cursorLeft,
1560
+ cursorRect: {
1561
+ top: cursorTop,
1562
+ bottom: cursorBottom,
1563
+ left: cursorLeft
1564
+ }
1551
1565
  };
1552
1566
  } catch {
1553
- return { top: 0, left: 0 };
1567
+ return empty;
1554
1568
  }
1555
1569
  }, []);
1556
1570
  const wrapSelection = useCallback(
@@ -1818,7 +1832,11 @@ var InkwellEditorClient = forwardRef(
1818
1832
  query: activePlugin === plugin ? activePluginQuery : "",
1819
1833
  onSelect: handlePluginSelect,
1820
1834
  onDismiss: dismissPlugin,
1821
- position: pluginPositionRef.current,
1835
+ position: {
1836
+ top: pluginPositionRef.current.top,
1837
+ left: pluginPositionRef.current.left
1838
+ },
1839
+ cursorRect: pluginPositionRef.current.cursorRect,
1822
1840
  editorRef: editorElRef,
1823
1841
  editor: pluginEditor,
1824
1842
  wrapSelection,
@@ -1994,7 +2012,7 @@ var InkwellEditorClient = forwardRef(
1994
2012
  className: `inkwell-editor-wrapper${hasCharacterLimit ? " inkwell-editor-has-character-limit" : ""}${overLimit ? " inkwell-editor-over-limit" : ""}${className ? ` ${className}` : ""}${classNames?.root ? ` ${classNames.root}` : ""}`,
1995
2013
  style: styles?.root,
1996
2014
  children: [
1997
- hasCharacterLimit && /* @__PURE__ */ jsx(
2015
+ showCharacterCount && /* @__PURE__ */ jsx(
1998
2016
  CharacterCount,
1999
2017
  {
2000
2018
  count: characterCount,
@@ -2153,16 +2171,41 @@ var insertUploadedAttachment = (file, options) => {
2153
2171
  options.onError?.(err, file);
2154
2172
  });
2155
2173
  };
2156
- function createAttachmentsPlugin(options) {
2174
+ function routeFiles(editor, files, options) {
2157
2175
  const { accept } = options;
2176
+ const matching = accept ? files.filter((f) => mimeMatches(f.type, accept)) : files;
2177
+ const handled = matching.filter(
2178
+ (f) => isImageFile(f) || options.onAttachmentAdd !== void 0
2179
+ );
2180
+ for (const file of handled) {
2181
+ if (isImageFile(file)) {
2182
+ insertUploadedImage(editor, file, options);
2183
+ } else {
2184
+ insertUploadedAttachment(file, options);
2185
+ }
2186
+ }
2187
+ const skipped = files.filter((f) => !handled.includes(f));
2188
+ return { handled, skipped };
2189
+ }
2190
+ function createAttachmentsPlugin(options) {
2158
2191
  return {
2159
2192
  name: "attachments",
2193
+ setup(editor) {
2194
+ if (!options.ref) return;
2195
+ const writableRef = options.ref;
2196
+ writableRef.current = {
2197
+ upload: (files) => {
2198
+ if (files.length === 0) return;
2199
+ routeFiles(editor, files, options);
2200
+ }
2201
+ };
2202
+ return () => {
2203
+ writableRef.current = null;
2204
+ };
2205
+ },
2160
2206
  onInsertData(data, { editor, insertData }) {
2161
2207
  const files = extractFiles(data);
2162
- const matching = accept ? files.filter((f) => mimeMatches(f.type, accept)) : files;
2163
- const handled = matching.filter(
2164
- (f) => isImageFile(f) || options.onAttachmentAdd !== void 0
2165
- );
2208
+ const { handled, skipped } = routeFiles(editor, files, options);
2166
2209
  if (handled.length === 0) {
2167
2210
  const htmlImages = extractHtmlImages(data);
2168
2211
  if (htmlImages.length === 0) return false;
@@ -2171,16 +2214,8 @@ function createAttachmentsPlugin(options) {
2171
2214
  }
2172
2215
  return true;
2173
2216
  }
2174
- const unhandled = files.filter((f) => !handled.includes(f));
2175
- if (unhandled.length > 0) {
2176
- insertData(filesOnlyDataTransfer(unhandled));
2177
- }
2178
- for (const file of handled) {
2179
- if (isImageFile(file)) {
2180
- insertUploadedImage(editor, file, options);
2181
- } else {
2182
- insertUploadedAttachment(file, options);
2183
- }
2217
+ if (skipped.length > 0) {
2218
+ insertData(filesOnlyDataTransfer(skipped));
2184
2219
  }
2185
2220
  return true;
2186
2221
  }
@@ -2258,8 +2293,10 @@ function createCompletionsPlugin({
2258
2293
  var BASE = "inkwell-plugin-picker";
2259
2294
  var pluginPickerClass = {
2260
2295
  popup: `${BASE}-popup`,
2296
+ popupFlipped: `${BASE}-popup-flipped`,
2261
2297
  picker: `${BASE}`,
2262
2298
  search: `${BASE}-search`,
2299
+ list: `${BASE}-list`,
2263
2300
  item: `${BASE}-item`,
2264
2301
  itemActive: `${BASE}-item-active`,
2265
2302
  empty: `${BASE}-empty`,
@@ -2267,6 +2304,74 @@ var pluginPickerClass = {
2267
2304
  subtitle: `${BASE}-subtitle`,
2268
2305
  preview: `${BASE}-preview`
2269
2306
  };
2307
+ var POPUP_GAP = 4;
2308
+ var VIEWPORT_MARGIN = 8;
2309
+ var CURSOR_HEIGHT_FALLBACK = 20;
2310
+ function usePickerPlacement(position, cursorRect) {
2311
+ const [popupEl, setPopupEl] = useState(null);
2312
+ const [placement, setPlacement] = useState({ flippedAbove: false, leftOverride: null });
2313
+ const cursorTopWrapper = cursorRect?.top ?? Math.max(0, position.top - POPUP_GAP - CURSOR_HEIGHT_FALLBACK);
2314
+ const cursorBottomWrapper = cursorRect?.bottom ?? Math.max(0, position.top - POPUP_GAP);
2315
+ const cursorLeftWrapper = cursorRect?.left ?? position.left;
2316
+ useLayoutEffect(() => {
2317
+ if (!popupEl) return;
2318
+ const measure = () => {
2319
+ const offsetParent = popupEl.offsetParent;
2320
+ if (!(offsetParent instanceof HTMLElement)) return;
2321
+ const wrapperRect = offsetParent.getBoundingClientRect();
2322
+ const popupRect = popupEl.getBoundingClientRect();
2323
+ const popupHeight = popupRect.height;
2324
+ const popupWidth = popupRect.width;
2325
+ const cursorTopViewport = wrapperRect.top + cursorTopWrapper;
2326
+ const cursorBottomViewport = wrapperRect.top + cursorBottomWrapper;
2327
+ const cursorLeftViewport = wrapperRect.left + cursorLeftWrapper;
2328
+ const spaceBelowViewport = window.innerHeight - cursorBottomViewport - VIEWPORT_MARGIN;
2329
+ const spaceBelowWrapper = wrapperRect.bottom - cursorBottomViewport;
2330
+ const spaceBelow = Math.min(spaceBelowViewport, spaceBelowWrapper);
2331
+ const spaceAbove = cursorTopViewport - VIEWPORT_MARGIN;
2332
+ const needsFlip = popupHeight + POPUP_GAP > spaceBelow;
2333
+ const fitsAbove = popupHeight + POPUP_GAP <= spaceAbove;
2334
+ const flippedAbove = needsFlip && fitsAbove;
2335
+ const maxRightViewport = window.innerWidth - VIEWPORT_MARGIN;
2336
+ const popupRightIfDefault = cursorLeftViewport + popupWidth;
2337
+ const leftOverride = popupRightIfDefault > maxRightViewport ? Math.max(
2338
+ VIEWPORT_MARGIN - wrapperRect.left,
2339
+ maxRightViewport - popupWidth - wrapperRect.left
2340
+ ) : null;
2341
+ setPlacement(
2342
+ (prev) => prev.flippedAbove === flippedAbove && prev.leftOverride === leftOverride ? prev : { flippedAbove, leftOverride }
2343
+ );
2344
+ };
2345
+ measure();
2346
+ const ResizeObserverCtor = typeof window !== "undefined" ? window.ResizeObserver : void 0;
2347
+ const resizeObserver = ResizeObserverCtor ? new ResizeObserverCtor(() => measure()) : null;
2348
+ resizeObserver?.observe(popupEl);
2349
+ window.addEventListener("resize", measure);
2350
+ window.addEventListener("scroll", measure, true);
2351
+ return () => {
2352
+ resizeObserver?.disconnect();
2353
+ window.removeEventListener("resize", measure);
2354
+ window.removeEventListener("scroll", measure, true);
2355
+ };
2356
+ }, [popupEl, cursorTopWrapper, cursorBottomWrapper, cursorLeftWrapper]);
2357
+ const style = {
2358
+ position: "absolute",
2359
+ top: placement.flippedAbove ? cursorTopWrapper - POPUP_GAP : position.top,
2360
+ left: placement.leftOverride ?? position.left,
2361
+ transform: placement.flippedAbove ? "translateY(-100%)" : void 0,
2362
+ zIndex: 1001
2363
+ };
2364
+ const className = placement.flippedAbove ? `${pluginPickerClass.popup} ${pluginPickerClass.popupFlipped}` : pluginPickerClass.popup;
2365
+ return {
2366
+ setPopupEl,
2367
+ style,
2368
+ className,
2369
+ flippedAbove: placement.flippedAbove
2370
+ };
2371
+ }
2372
+ function usePluginPopupPlacement(position, cursorRect) {
2373
+ return usePickerPlacement(position, cursorRect);
2374
+ }
2270
2375
  function PluginMenuPrimitive({
2271
2376
  items,
2272
2377
  search,
@@ -2278,6 +2383,7 @@ function PluginMenuPrimitive({
2278
2383
  onSelect,
2279
2384
  onDismiss,
2280
2385
  position,
2386
+ cursorRect,
2281
2387
  subscribeForwardedKey
2282
2388
  }) {
2283
2389
  const [selectedIndex, setSelectedIndex] = useState(0);
@@ -2422,16 +2528,13 @@ function PluginMenuPrimitive({
2422
2528
  updateSelectedIndex
2423
2529
  ]
2424
2530
  );
2531
+ const placement = usePickerPlacement(position, cursorRect);
2425
2532
  return /* @__PURE__ */ jsx(
2426
2533
  "div",
2427
2534
  {
2428
- className: pluginPickerClass.popup,
2429
- style: {
2430
- position: "absolute",
2431
- top: position.top,
2432
- left: position.left,
2433
- zIndex: 1001
2434
- },
2535
+ ref: placement.setPopupEl,
2536
+ className: placement.className,
2537
+ style: placement.style,
2435
2538
  onMouseDown: (event) => event.preventDefault(),
2436
2539
  children: /* @__PURE__ */ jsxs(
2437
2540
  "div",
@@ -2445,7 +2548,7 @@ function PluginMenuPrimitive({
2445
2548
  "aria-activedescendant": results.length > 0 ? activeOptionId : void 0,
2446
2549
  children: [
2447
2550
  /* @__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 })
2551
+ 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
2552
  ]
2450
2553
  }
2451
2554
  )
@@ -2607,6 +2710,7 @@ var SlashCommandMenuInner = forwardRef(function SlashCommandMenuInner2({
2607
2710
  onExecute,
2608
2711
  onDismiss,
2609
2712
  position,
2713
+ cursorRect,
2610
2714
  getEditor
2611
2715
  }, ref) {
2612
2716
  const [mode, setMode] = useState("commands");
@@ -2770,17 +2874,14 @@ var SlashCommandMenuInner = forwardRef(function SlashCommandMenuInner2({
2770
2874
  selectedIndex
2771
2875
  ]
2772
2876
  );
2877
+ const placement = usePluginPopupPlacement(position, cursorRect);
2773
2878
  if (mode === "ready" && selectedCommand) {
2774
2879
  return /* @__PURE__ */ jsx(
2775
2880
  "div",
2776
2881
  {
2777
- className: pluginPickerClass.popup,
2778
- style: {
2779
- position: "absolute",
2780
- top: position.top,
2781
- left: position.left,
2782
- zIndex: 1001
2783
- },
2882
+ ref: placement.setPopupEl,
2883
+ className: placement.className,
2884
+ style: placement.style,
2784
2885
  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
2886
  }
2786
2887
  );
@@ -2788,13 +2889,9 @@ var SlashCommandMenuInner = forwardRef(function SlashCommandMenuInner2({
2788
2889
  return /* @__PURE__ */ jsx(
2789
2890
  "div",
2790
2891
  {
2791
- className: pluginPickerClass.popup,
2792
- style: {
2793
- position: "absolute",
2794
- top: position.top,
2795
- left: position.left,
2796
- zIndex: 1001
2797
- },
2892
+ ref: placement.setPopupEl,
2893
+ className: placement.className,
2894
+ style: placement.style,
2798
2895
  children: /* @__PURE__ */ jsxs("div", { className: pluginPickerClass.picker, children: [
2799
2896
  /* @__PURE__ */ jsx("div", { className: pluginPickerClass.search, children: mode === "commands" ? `/${query}` : `${selectedCommand ? `/${selectedCommand.name} ` : ""}${query}` }),
2800
2897
  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 +2900,7 @@ var SlashCommandMenuInner = forwardRef(function SlashCommandMenuInner2({
2803
2900
  id: listboxId,
2804
2901
  role: "listbox",
2805
2902
  "aria-activedescendant": activeOptionId,
2903
+ className: pluginPickerClass.list,
2806
2904
  children: items.map((item, index) => {
2807
2905
  const active = index === selectedIndex;
2808
2906
  return /* @__PURE__ */ jsxs(
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@railway/inkwell",
3
- "version": "1.1.0",
3
+ "version": "1.2.0",
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
@@ -31,6 +31,7 @@
31
31
  --inkwell-accent: hsl(217, 91%, 50%);
32
32
  --inkwell-accent-soft: hsla(217, 91%, 50%, 0.12);
33
33
  --inkwell-danger: hsl(0, 72%, 51%);
34
+ --inkwell-danger-soft: hsla(0, 72%, 51%, 0.14);
34
35
 
35
36
  /* Inline code */
36
37
  --inkwell-code-bg: hsl(220, 14%, 94%);
@@ -61,6 +62,8 @@
61
62
 
62
63
  --inkwell-accent: hsl(217, 91%, 65%);
63
64
  --inkwell-accent-soft: hsla(217, 91%, 65%, 0.16);
65
+ --inkwell-danger: hsl(0, 70%, 65%);
66
+ --inkwell-danger-soft: hsla(0, 70%, 65%, 0.18);
64
67
 
65
68
  --inkwell-code-bg: hsl(220, 13%, 18%);
66
69
  --inkwell-code-fg: hsl(340, 70%, 75%);
@@ -72,21 +75,34 @@
72
75
  .inkwell-editor-wrapper {
73
76
  position: relative;
74
77
  }
75
- .inkwell-editor {
76
- min-height: 200px;
78
+
79
+ /* Visual-chrome defaults (padding, border, background, type) are wrapped in
80
+ `:where()` so they carry 0,0,0 specificity. Any single-class consumer rule
81
+ on `.inkwell-editor` (Tailwind utilities, `classNames.editor`, etc.) wins
82
+ automatically — no `!important` or selector gymnastics needed.
83
+
84
+ Container size (min-height, max-height, height) is deliberately NOT
85
+ defaulted here. Inkwell sits inside the consumer's layout and the right
86
+ size is a consumer decision: a full-page editor wants `min-height: 60vh`,
87
+ a chat composer wants `min-height: 0`, a panel just wants to fill its
88
+ container. Set it on the editor via `styles.editor`, `classNames.editor`,
89
+ or your own CSS. */
90
+ :where(.inkwell-editor) {
77
91
  padding: 1rem 1.25rem;
78
- outline: none;
79
92
  border: 1px solid var(--inkwell-border);
80
93
  border-radius: var(--inkwell-radius);
81
94
  background: var(--inkwell-bg);
82
- color: var(--inkwell-text);
83
95
  line-height: 1.6;
84
96
  font-size: 0.95rem;
85
97
  transition: border-color 0.15s ease;
86
98
  }
87
- .inkwell-editor:focus-within {
99
+ :where(.inkwell-editor:focus-within) {
88
100
  border-color: var(--inkwell-border-strong);
89
101
  }
102
+ .inkwell-editor {
103
+ outline: none;
104
+ color: var(--inkwell-text);
105
+ }
90
106
 
91
107
  .inkwell-editor p {
92
108
  margin: 0;
@@ -162,34 +178,35 @@
162
178
  height: auto;
163
179
  }
164
180
 
165
- /* Built-in character count. Anchored to the bottom-right of the editor
166
- wrapper. `characterLimit` is a soft hint typing past the limit is
167
- allowed; the count turns red and the wrapper picks up
168
- `.inkwell-editor-over-limit`, which paints a red ring on the editor
169
- surface so it's visually obvious the document is over budget. */
181
+ /* Built-in character count. Overlays the top-right corner of the editor
182
+ wrapper so it never shifts content, and sits on a solid surface tint
183
+ so it visually layers above any text that wraps underneath. Only
184
+ rendered once typing reaches 80% of `characterLimit`, since the
185
+ limit is a soft hint typing past it is allowed; the count then
186
+ turns red and the wrapper picks up `.inkwell-editor-over-limit`,
187
+ which paints a soft red halo on the editor surface so it's visually
188
+ obvious the document is over budget. */
170
189
  .inkwell-editor-character-count {
171
190
  position: absolute;
172
- right: 0.7rem;
173
- bottom: 0.7rem;
191
+ top: 0.5rem;
192
+ right: 0.5rem;
174
193
  z-index: 10;
175
- padding: 0.15rem 0.45rem;
194
+ padding: 0.1rem 0.4rem;
176
195
  font-size: 0.72rem;
177
196
  font-variant-numeric: tabular-nums;
178
197
  color: var(--inkwell-text-dim);
198
+ background: var(--inkwell-bg);
199
+ border-radius: calc(var(--inkwell-radius) - 2px);
179
200
  pointer-events: none;
180
201
  user-select: none;
181
202
  }
182
203
  .inkwell-editor-character-count-over {
183
- color: hsl(0, 75%, 55%);
204
+ color: var(--inkwell-danger);
184
205
  font-weight: 500;
185
206
  }
186
- .inkwell-editor-wrapper.inkwell-editor-has-character-limit .inkwell-editor {
187
- padding-right: 5.25rem;
188
- padding-bottom: 2.25rem;
189
- }
190
207
  .inkwell-editor-wrapper.inkwell-editor-over-limit .inkwell-editor {
191
- border-color: hsl(0, 75%, 55%);
192
- box-shadow: 0 0 0 1px hsl(0, 75%, 55%);
208
+ border-color: var(--inkwell-danger-soft);
209
+ box-shadow: 0 0 0 3px var(--inkwell-danger-soft);
193
210
  }
194
211
 
195
212
  .inkwell-editor-backtick,
@@ -303,6 +320,28 @@
303
320
  .inkwell-plugin-picker-search::placeholder {
304
321
  color: var(--inkwell-text-dim);
305
322
  }
323
+ .inkwell-plugin-picker-list {
324
+ max-height: 240px;
325
+ overflow-y: auto;
326
+ overscroll-behavior: contain;
327
+ scrollbar-width: thin;
328
+ scrollbar-color: var(--inkwell-border-strong) transparent;
329
+ }
330
+ .inkwell-plugin-picker-list::-webkit-scrollbar {
331
+ width: 8px;
332
+ }
333
+ .inkwell-plugin-picker-list::-webkit-scrollbar-track {
334
+ background: transparent;
335
+ }
336
+ .inkwell-plugin-picker-list::-webkit-scrollbar-thumb {
337
+ background-color: var(--inkwell-border-strong);
338
+ border-radius: 4px;
339
+ border: 2px solid transparent;
340
+ background-clip: padding-box;
341
+ }
342
+ .inkwell-plugin-picker-list::-webkit-scrollbar-thumb:hover {
343
+ background-color: var(--inkwell-text-dim);
344
+ }
306
345
  .inkwell-plugin-picker-item {
307
346
  padding: 7px 10px;
308
347
  cursor: pointer;
@@ -348,9 +387,13 @@
348
387
 
349
388
  /* ── Renderer ────────────────────────────────────────────────────── */
350
389
 
351
- .inkwell-renderer {
390
+ /* Layout defaults on the renderer follow the same low-specificity pattern
391
+ as the editor — a single-class consumer rule wins automatically. */
392
+ :where(.inkwell-renderer) {
352
393
  line-height: 1.65;
353
394
  font-size: 0.95rem;
395
+ }
396
+ .inkwell-renderer {
354
397
  color: var(--inkwell-text);
355
398
  }
356
399
  .inkwell-renderer :first-child {