@inshapardaz/likhari-react 0.1.6-dev.62 → 0.1.6-dev.64

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.
@@ -15,6 +15,14 @@ export interface EditorRootProps {
15
15
  featurePreset?: FeatureConfigPresetName;
16
16
  theme?: MantineThemeOverride;
17
17
  colorScheme?: 'light' | 'dark';
18
+ /**
19
+ * A CSS color (e.g. `'#2B6E6E'`) overriding the editor's default accent —
20
+ * both Mantine's own primary color (buttons, portaled dropdowns' active-item
21
+ * highlight, etc.) and the `--editor-accent` / `--editor-accent-soft` CSS
22
+ * variables the toolbar/canvas use directly. Omit to use the built-in
23
+ * default tokens (packages/core/src/theme/tokens.ts).
24
+ */
25
+ accentColor?: string;
18
26
  locale?: Locale;
19
27
  placeholder?: string;
20
28
  /**
@@ -1 +1 @@
1
- {"version":3,"file":"EditorRoot.d.ts","sourceRoot":"","sources":["../src/EditorRoot.tsx"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,oBAAoB,EAAE,MAAM,eAAe,CAAC;AAU1D,OAAO,KAAK,EAAe,qBAAqB,EAAE,MAAM,SAAS,CAAC;AAClE,OAAO,EAAyB,KAAK,QAAQ,EAAE,MAAM,iCAAiC,CAAC;AACvF,OAAO,EAAwB,KAAK,mBAAmB,EAAE,KAAK,uBAAuB,EAAE,MAAM,2BAA2B,CAAC;AAKzH,OAAO,EAAyB,KAAK,UAAU,EAAE,MAAM,SAAS,CAAC;AAGjE,OAAO,EAAgC,KAAK,MAAM,EAAE,MAAM,QAAQ,CAAC;AAEnE,MAAM,WAAW,oBAAoB;IACnC,MAAM,EAAE,OAAO,CAAC,QAAQ,EAAE,cAAc,GAAG,YAAY,CAAC,CAAC;IACzD,KAAK,EAAE,MAAM,CAAC;CACf;AAED,MAAM,WAAW,eAAe;IAC9B,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,cAAc,CAAC,EAAE,oBAAoB,CAAC;IACtC,aAAa,CAAC,EAAE,mBAAmB,CAAC;IACpC,aAAa,CAAC,EAAE,uBAAuB,CAAC;IACxC,KAAK,CAAC,EAAE,oBAAoB,CAAC;IAC7B,WAAW,CAAC,EAAE,OAAO,GAAG,MAAM,CAAC;IAC/B,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB;;;;;;;OAOG;IACH,MAAM,CAAC,EAAE,MAAM,GAAG,MAAM,CAAC;IACzB,QAAQ,CAAC,EAAE,CAAC,KAAK,EAAE,qBAAqB,KAAK,IAAI,CAAC;IAClD;;;OAGG;IACH,MAAM,CAAC,EAAE,CAAC,OAAO,EAAE,MAAM,EAAE,MAAM,EAAE,QAAQ,KAAK,IAAI,CAAC;IACrD;;;;;OAKG;IACH,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB;;;;OAIG;IACH,aAAa,CAAC,EAAE,CAAC,IAAI,EAAE,IAAI,KAAK,OAAO,CAAC,MAAM,CAAC,CAAC;IAChD;;;;;;;OAOG;IACH,UAAU,CAAC,EAAE,CAAC,GAAG,EAAE,MAAM,KAAK,OAAO,CAAC,IAAI,CAAC,CAAC;IAC5C;;;;;;OAMG;IACH,WAAW,CAAC,EAAE,UAAU,EAAE,CAAC;CAC5B;AAED,MAAM,WAAW,SAAS;IACxB,UAAU,CAAC,MAAM,EAAE,QAAQ,GAAG,MAAM,CAAC;IACrC,UAAU,CAAC,KAAK,EAAE,MAAM,EAAE,MAAM,EAAE,QAAQ,GAAG,IAAI,CAAC;IAClD,iBAAiB,IAAI,OAAO,CAAC;IAC7B,cAAc,IAAI,OAAO,CAAC,OAAO,CAAC,CAAC;IACnC,KAAK,IAAI,IAAI,CAAC;CACf;AAQD,eAAO,MAAM,UAAU,uGAkJrB,CAAC"}
1
+ {"version":3,"file":"EditorRoot.d.ts","sourceRoot":"","sources":["../src/EditorRoot.tsx"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,oBAAoB,EAAE,MAAM,eAAe,CAAC;AAU1D,OAAO,KAAK,EAAe,qBAAqB,EAAE,MAAM,SAAS,CAAC;AAClE,OAAO,EAAyB,KAAK,QAAQ,EAAE,MAAM,iCAAiC,CAAC;AACvF,OAAO,EAAwB,KAAK,mBAAmB,EAAE,KAAK,uBAAuB,EAAE,MAAM,2BAA2B,CAAC;AAKzH,OAAO,EAAyB,KAAK,UAAU,EAAE,MAAM,SAAS,CAAC;AAIjE,OAAO,EAAgC,KAAK,MAAM,EAAE,MAAM,QAAQ,CAAC;AAEnE,MAAM,WAAW,oBAAoB;IACnC,MAAM,EAAE,OAAO,CAAC,QAAQ,EAAE,cAAc,GAAG,YAAY,CAAC,CAAC;IACzD,KAAK,EAAE,MAAM,CAAC;CACf;AAED,MAAM,WAAW,eAAe;IAC9B,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,cAAc,CAAC,EAAE,oBAAoB,CAAC;IACtC,aAAa,CAAC,EAAE,mBAAmB,CAAC;IACpC,aAAa,CAAC,EAAE,uBAAuB,CAAC;IACxC,KAAK,CAAC,EAAE,oBAAoB,CAAC;IAC7B,WAAW,CAAC,EAAE,OAAO,GAAG,MAAM,CAAC;IAC/B;;;;;;OAMG;IACH,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB;;;;;;;OAOG;IACH,MAAM,CAAC,EAAE,MAAM,GAAG,MAAM,CAAC;IACzB,QAAQ,CAAC,EAAE,CAAC,KAAK,EAAE,qBAAqB,KAAK,IAAI,CAAC;IAClD;;;OAGG;IACH,MAAM,CAAC,EAAE,CAAC,OAAO,EAAE,MAAM,EAAE,MAAM,EAAE,QAAQ,KAAK,IAAI,CAAC;IACrD;;;;;OAKG;IACH,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB;;;;OAIG;IACH,aAAa,CAAC,EAAE,CAAC,IAAI,EAAE,IAAI,KAAK,OAAO,CAAC,MAAM,CAAC,CAAC;IAChD;;;;;;;OAOG;IACH,UAAU,CAAC,EAAE,CAAC,GAAG,EAAE,MAAM,KAAK,OAAO,CAAC,IAAI,CAAC,CAAC;IAC5C;;;;;;OAMG;IACH,WAAW,CAAC,EAAE,UAAU,EAAE,CAAC;CAC5B;AAED,MAAM,WAAW,SAAS;IACxB,UAAU,CAAC,MAAM,EAAE,QAAQ,GAAG,MAAM,CAAC;IACrC,UAAU,CAAC,KAAK,EAAE,MAAM,EAAE,MAAM,EAAE,QAAQ,GAAG,IAAI,CAAC;IAClD,iBAAiB,IAAI,OAAO,CAAC;IAC7B,cAAc,IAAI,OAAO,CAAC,OAAO,CAAC,CAAC;IACnC,KAAK,IAAI,IAAI,CAAC;CACf;AAQD,eAAO,MAAM,UAAU,uGAkKrB,CAAC"}
@@ -1,5 +1,5 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { forwardRef, useEffect, useImperativeHandle, useMemo, useRef, useState } from 'react';
2
+ import { forwardRef, useEffect, useId, useImperativeHandle, useMemo, useRef, useState } from 'react';
3
3
  import { LexicalComposer } from '@lexical/react/LexicalComposer';
4
4
  import { RichTextPlugin } from '@lexical/react/LexicalRichTextPlugin';
5
5
  import { ContentEditable } from '@lexical/react/LexicalContentEditable';
@@ -18,6 +18,7 @@ import { Toolbar } from './components/Toolbar.js';
18
18
  import { injectUrduWebFontsCss } from './fonts/index.js';
19
19
  import { LinkPastePlugin } from './plugins/LinkPastePlugin.js';
20
20
  import { ImageOptionsContext } from './image/ImageOptionsContext.js';
21
+ import { PortalTargetContext } from './PortalTargetContext.js';
21
22
  import { UiStringsContext, getStrings } from './i18n/index.js';
22
23
  function initialEditorStateJson(initialContent) {
23
24
  if (!initialContent)
@@ -25,7 +26,7 @@ function initialEditorStateJson(initialContent) {
25
26
  const state = defaultFormatRegistry.parse(initialContent.format, initialContent.value);
26
27
  return JSON.stringify(state);
27
28
  }
28
- export const EditorRoot = forwardRef(function EditorRoot({ documentId, initialContent, featureConfig, featurePreset, theme, colorScheme, locale = 'en', placeholder, height = '480px', onChange, onSave, showSave = Boolean(onSave), onImageUpload, fetchImage, fontOptions, }, ref) {
29
+ export const EditorRoot = forwardRef(function EditorRoot({ documentId, initialContent, featureConfig, featurePreset, theme, colorScheme, accentColor, locale = 'en', placeholder, height = '480px', onChange, onSave, showSave = Boolean(onSave), onImageUpload, fetchImage, fontOptions, }, ref) {
29
30
  const config = useMemo(() => resolveFeatureConfig(featureConfig, featurePreset), [featureConfig, featurePreset]);
30
31
  const strings = useMemo(() => getStrings(locale), [locale]);
31
32
  const resolvedPlaceholder = placeholder ?? strings.editor.placeholder;
@@ -105,5 +106,10 @@ export const EditorRoot = forwardRef(function EditorRoot({ documentId, initialCo
105
106
  onSave?.(content, format);
106
107
  };
107
108
  const dir = locale === 'en' ? 'ltr' : 'rtl';
108
- return (_jsx(EditorThemeProvider, { theme: theme, colorScheme: colorScheme, children: _jsx("div", { className: "likhari-root", dir: dir, ref: rootElementRef, "data-document-id": documentId, style: { height: typeof height === 'number' ? `${height}px` : height }, children: _jsx(UiStringsContext.Provider, { value: strings, children: _jsx(ImageOptionsContext.Provider, { value: imageOptions, children: _jsxs(LexicalComposer, { initialConfig: initialConfig, children: [_jsx(Toolbar, { config: config, onSave: handleSave, isDirty: isDirty, showSave: showSave, fontOptions: fontOptions, direction: dir, locale: locale }), _jsx("div", { className: "likhari-canvas", children: _jsx(RichTextPlugin, { contentEditable: _jsx(ContentEditable, { className: "likhari-content-editable", dir: dir, "aria-label": strings.editor.contentLabel }), placeholder: _jsx("div", { className: "likhari-placeholder", children: resolvedPlaceholder }), ErrorBoundary: LexicalErrorBoundary }) }), config.history && _jsx(HistoryPlugin, {}), (config.lists.bullet || config.lists.numbered || config.lists.check) && _jsx(ListPlugin, {}), config.lists.check && _jsx(CheckListPlugin, {}), config.links && _jsx(LinkPlugin, {}), config.links && _jsx(LinkPastePlugin, {}), _jsx(OnChangePlugin, { onChange: handleChange })] }) }) }) }) }));
109
+ // A stable per-instance id for the portal-anchor div below, so multiple
110
+ // EditorRoot instances on one page each get their own portal target
111
+ // rather than colliding on a shared one.
112
+ const portalTargetId = useId();
113
+ const portalTargetSelector = `#${CSS.escape(portalTargetId)}`;
114
+ return (_jsx(EditorThemeProvider, { theme: theme, colorScheme: colorScheme, accentColor: accentColor, children: _jsxs("div", { className: "likhari-root", dir: dir, ref: rootElementRef, "data-document-id": documentId, style: { height: typeof height === 'number' ? `${height}px` : height }, children: [_jsx("div", { id: portalTargetId, className: "likhari-portal-target" }), _jsx(PortalTargetContext.Provider, { value: portalTargetSelector, children: _jsx(UiStringsContext.Provider, { value: strings, children: _jsx(ImageOptionsContext.Provider, { value: imageOptions, children: _jsxs(LexicalComposer, { initialConfig: initialConfig, children: [_jsx(Toolbar, { config: config, onSave: handleSave, isDirty: isDirty, showSave: showSave, fontOptions: fontOptions, direction: dir, locale: locale }), _jsx("div", { className: "likhari-canvas", children: _jsx(RichTextPlugin, { contentEditable: _jsx(ContentEditable, { className: "likhari-content-editable", dir: dir, "aria-label": strings.editor.contentLabel }), placeholder: _jsx("div", { className: "likhari-placeholder", children: resolvedPlaceholder }), ErrorBoundary: LexicalErrorBoundary }) }), config.history && _jsx(HistoryPlugin, {}), (config.lists.bullet || config.lists.numbered || config.lists.check) && _jsx(ListPlugin, {}), config.lists.check && _jsx(CheckListPlugin, {}), config.links && _jsx(LinkPlugin, {}), config.links && _jsx(LinkPastePlugin, {}), _jsx(OnChangePlugin, { onChange: handleChange })] }) }) }) })] }) }));
109
115
  });
@@ -0,0 +1,16 @@
1
+ /**
2
+ * CSS selector for the portal-anchor `<div>` `EditorRoot` renders as the
3
+ * first child of `.likhari-root` (see EditorRoot.tsx). Mantine's `Menu`,
4
+ * `Modal`, `Select`'s combobox and `Tooltip` all default to portaling their
5
+ * content to `document.body` — outside the DOM subtree that carries the
6
+ * `--editor-*` CSS custom properties (`.likhari-theme-scope`) and the `dir`
7
+ * attribute (`.likhari-root`), so none of that styling/direction reaches
8
+ * them. Passing this selector as the target fixes that by portaling into a
9
+ * genuine descendant of both instead.
10
+ *
11
+ * `undefined` (the default, outside any `EditorRoot`, or before it has
12
+ * mounted) falls back to Mantine's own default portal behavior.
13
+ */
14
+ export declare const PortalTargetContext: import("react").Context<string | undefined>;
15
+ export declare function usePortalTarget(): string | undefined;
16
+ //# sourceMappingURL=PortalTargetContext.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"PortalTargetContext.d.ts","sourceRoot":"","sources":["../src/PortalTargetContext.tsx"],"names":[],"mappings":"AAEA;;;;;;;;;;;;GAYG;AACH,eAAO,MAAM,mBAAmB,6CAA+C,CAAC;AAEhF,wBAAgB,eAAe,IAAI,MAAM,GAAG,SAAS,CAEpD"}
@@ -0,0 +1,18 @@
1
+ import { createContext, useContext } from 'react';
2
+ /**
3
+ * CSS selector for the portal-anchor `<div>` `EditorRoot` renders as the
4
+ * first child of `.likhari-root` (see EditorRoot.tsx). Mantine's `Menu`,
5
+ * `Modal`, `Select`'s combobox and `Tooltip` all default to portaling their
6
+ * content to `document.body` — outside the DOM subtree that carries the
7
+ * `--editor-*` CSS custom properties (`.likhari-theme-scope`) and the `dir`
8
+ * attribute (`.likhari-root`), so none of that styling/direction reaches
9
+ * them. Passing this selector as the target fixes that by portaling into a
10
+ * genuine descendant of both instead.
11
+ *
12
+ * `undefined` (the default, outside any `EditorRoot`, or before it has
13
+ * mounted) falls back to Mantine's own default portal behavior.
14
+ */
15
+ export const PortalTargetContext = createContext(undefined);
16
+ export function usePortalTarget() {
17
+ return useContext(PortalTargetContext);
18
+ }
@@ -1 +1 @@
1
- {"version":3,"file":"LinkDialog.d.ts","sourceRoot":"","sources":["../../src/components/LinkDialog.tsx"],"names":[],"mappings":"AAKA,MAAM,WAAW,eAAe;IAC9B,MAAM,EAAE,OAAO,CAAC;IAChB,8DAA8D;IAC9D,UAAU,EAAE,MAAM,CAAC;IACnB,wFAAwF;IACxF,aAAa,EAAE,OAAO,CAAC;IACvB,QAAQ,EAAE,CAAC,IAAI,EAAE;QAAE,GAAG,EAAE,MAAM,CAAC;QAAC,IAAI,EAAE,MAAM,CAAA;KAAE,KAAK,IAAI,CAAC;IACxD,kDAAkD;IAClD,QAAQ,CAAC,EAAE,MAAM,IAAI,CAAC;IACtB,OAAO,EAAE,MAAM,IAAI,CAAC;CACrB;AAED;gDACgD;AAChD,wBAAgB,UAAU,CAAC,EAAE,MAAM,EAAE,UAAU,EAAE,aAAa,EAAE,QAAQ,EAAE,QAAQ,EAAE,OAAO,EAAE,EAAE,eAAe,+BAiE7G"}
1
+ {"version":3,"file":"LinkDialog.d.ts","sourceRoot":"","sources":["../../src/components/LinkDialog.tsx"],"names":[],"mappings":"AAMA,MAAM,WAAW,eAAe;IAC9B,MAAM,EAAE,OAAO,CAAC;IAChB,8DAA8D;IAC9D,UAAU,EAAE,MAAM,CAAC;IACnB,wFAAwF;IACxF,aAAa,EAAE,OAAO,CAAC;IACvB,QAAQ,EAAE,CAAC,IAAI,EAAE;QAAE,GAAG,EAAE,MAAM,CAAC;QAAC,IAAI,EAAE,MAAM,CAAA;KAAE,KAAK,IAAI,CAAC;IACxD,kDAAkD;IAClD,QAAQ,CAAC,EAAE,MAAM,IAAI,CAAC;IACtB,OAAO,EAAE,MAAM,IAAI,CAAC;CACrB;AAED;gDACgD;AAChD,wBAAgB,UAAU,CAAC,EAAE,MAAM,EAAE,UAAU,EAAE,aAAa,EAAE,QAAQ,EAAE,QAAQ,EAAE,OAAO,EAAE,EAAE,eAAe,+BAyE7G"}
@@ -3,10 +3,12 @@ import { useEffect, useState } from 'react';
3
3
  import { Button, Group, Modal, TextInput } from '@mantine/core';
4
4
  import { normalizeLinkUrl } from '../utils/linkUrl.js';
5
5
  import { useUiStrings } from '../i18n/useStrings.js';
6
+ import { usePortalTarget } from '../PortalTargetContext.js';
6
7
  /** Insert/edit-link dialog (UI spec §6). Mantine Modal supplies focus
7
8
  * trapping, Escape-to-close and the overlay. */
8
9
  export function LinkDialog({ opened, initialUrl, showTextField, onSubmit, onRemove, onClose }) {
9
10
  const strings = useUiStrings();
11
+ const portalTarget = usePortalTarget();
10
12
  const [url, setUrl] = useState(initialUrl);
11
13
  const [text, setText] = useState('');
12
14
  const [error, setError] = useState(null);
@@ -26,7 +28,7 @@ export function LinkDialog({ opened, initialUrl, showTextField, onSubmit, onRemo
26
28
  }
27
29
  onSubmit({ url: normalized, text: text.trim() || normalized });
28
30
  };
29
- return (_jsx(Modal, { opened: opened, onClose: onClose, title: onRemove ? strings.linkDialog.titleEdit : strings.linkDialog.titleInsert, centered: true, size: "sm", children: _jsxs("form", { onSubmit: handleSubmit, children: [_jsx(TextInput, { label: strings.linkDialog.urlLabel, placeholder: strings.linkDialog.urlPlaceholder, value: url, onChange: (e) => {
31
+ return (_jsx(Modal, { opened: opened, onClose: onClose, title: onRemove ? strings.linkDialog.titleEdit : strings.linkDialog.titleInsert, centered: true, size: "sm", portalProps: { target: portalTarget }, children: _jsxs("form", { onSubmit: handleSubmit, children: [_jsx(TextInput, { label: strings.linkDialog.urlLabel, placeholder: strings.linkDialog.urlPlaceholder, value: url, onChange: (e) => {
30
32
  setUrl(e.currentTarget.value);
31
33
  setError(null);
32
34
  }, error: error, "data-autofocus": true }), showTextField && (_jsx(TextInput, { mt: "sm", label: strings.linkDialog.textLabel, placeholder: strings.linkDialog.textPlaceholder, value: text, onChange: (e) => setText(e.currentTarget.value) })), _jsxs(Group, { justify: "space-between", mt: "md", children: [onRemove ? (_jsx(Button, { variant: "subtle", color: "red", onClick: onRemove, children: strings.link.removeLink })) : (_jsx("span", {})), _jsxs(Group, { gap: "xs", children: [_jsx(Button, { variant: "default", onClick: onClose, children: strings.common.cancel }), _jsx(Button, { type: "submit", children: strings.linkDialog.apply })] })] })] }) }));
@@ -1 +1 @@
1
- {"version":3,"file":"Toolbar.d.ts","sourceRoot":"","sources":["../../src/components/Toolbar.tsx"],"names":[],"mappings":"AA4CA,OAAO,KAAK,EAAE,2BAA2B,EAAE,MAAM,2BAA2B,CAAC;AAC7E,OAAO,EAA6D,KAAK,UAAU,EAAE,MAAM,UAAU,CAAC;AAGtG,OAAO,KAAK,EAAE,MAAM,EAAW,MAAM,iBAAiB,CAAC;AAoPvD,MAAM,WAAW,YAAY;IAC3B,MAAM,EAAE,2BAA2B,CAAC;IACpC,MAAM,CAAC,EAAE,MAAM,IAAI,CAAC;IACpB,OAAO,CAAC,EAAE,OAAO,CAAC;IAClB,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,sEAAsE;IACtE,WAAW,CAAC,EAAE,UAAU,EAAE,CAAC;IAC3B,wFAAwF;IACxF,SAAS,CAAC,EAAE,KAAK,GAAG,KAAK,CAAC;IAC1B,oFAAoF;IACpF,MAAM,CAAC,EAAE,MAAM,CAAC;CACjB;AAED,wBAAgB,OAAO,CAAC,EAAE,MAAM,EAAE,MAAM,EAAE,OAAO,EAAE,QAAQ,EAAE,WAAkC,EAAE,SAAiB,EAAE,MAAa,EAAE,EAAE,YAAY,+BAqoBhJ"}
1
+ {"version":3,"file":"Toolbar.d.ts","sourceRoot":"","sources":["../../src/components/Toolbar.tsx"],"names":[],"mappings":"AA4CA,OAAO,KAAK,EAAE,2BAA2B,EAAE,MAAM,2BAA2B,CAAC;AAC7E,OAAO,EAA6D,KAAK,UAAU,EAAE,MAAM,UAAU,CAAC;AAItG,OAAO,KAAK,EAAE,MAAM,EAAW,MAAM,iBAAiB,CAAC;AAsRvD,MAAM,WAAW,YAAY;IAC3B,MAAM,EAAE,2BAA2B,CAAC;IACpC,MAAM,CAAC,EAAE,MAAM,IAAI,CAAC;IACpB,OAAO,CAAC,EAAE,OAAO,CAAC;IAClB,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,sEAAsE;IACtE,WAAW,CAAC,EAAE,UAAU,EAAE,CAAC;IAC3B,wFAAwF;IACxF,SAAS,CAAC,EAAE,KAAK,GAAG,KAAK,CAAC;IAC1B,oFAAoF;IACpF,MAAM,CAAC,EAAE,MAAM,CAAC;CACjB;AAED,wBAAgB,OAAO,CAAC,EAAE,MAAM,EAAE,MAAM,EAAE,OAAO,EAAE,QAAQ,EAAE,WAAkC,EAAE,SAAiB,EAAE,MAAa,EAAE,EAAE,YAAY,+BA4wBhJ"}
@@ -1,5 +1,5 @@
1
1
  import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { useCallback, useEffect, useRef, useState } from 'react';
2
+ import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
3
3
  import { Menu, Select } from '@mantine/core';
4
4
  import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext';
5
5
  import { $getSelection, $isRangeSelection, $isElementNode, $setSelection, $getRoot, $getNearestNodeFromDOMNode, $createTextNode, KEY_MODIFIER_COMMAND, COMMAND_PRIORITY_NORMAL, FORMAT_TEXT_COMMAND, FORMAT_ELEMENT_COMMAND, INDENT_CONTENT_COMMAND, OUTDENT_CONTENT_COMMAND, UNDO_COMMAND, REDO_COMMAND, SELECTION_CHANGE_COMMAND, CAN_UNDO_COMMAND, CAN_REDO_COMMAND, COMMAND_PRIORITY_LOW, } from 'lexical';
@@ -16,6 +16,7 @@ import { normalizeLinkUrl } from '../utils/linkUrl.js';
16
16
  import { CANVAS_FONT_DEFAULTS, DEFAULT_FONT_OPTIONS, FONT_SIZES_PX } from '../fonts/index.js';
17
17
  import { setStyleProperty } from '../utils/style.js';
18
18
  import { useStrings } from '../i18n/useStrings.js';
19
+ import { usePortalTarget } from '../PortalTargetContext.js';
19
20
  import { IconAbc, IconAlignCenter, IconAlignJustified, IconAlignLeft, IconAlignRight, IconArrowBackUp, IconArrowForwardUp, IconBold, IconClearFormatting, IconDeviceFloppy, IconDots, IconExternalLink, IconFeather, IconIndentDecrease, IconIndentIncrease, IconItalic, IconLetterCase, IconLetterCaseLower, IconLetterCaseUpper, IconLink, IconLinkOff, IconPencil, IconPhoto, IconPilcrow, IconSparkles, IconStrikethrough, IconSubscript, IconSuperscript, IconTextSize, IconTypography, IconUnderline, IconWand, } from '@tabler/icons-react';
20
21
  /** Sentinel for "no inline style": Lexical returns the default it is given when
21
22
  * the selection has none, so this tells "unstyled" (show the canvas default)
@@ -58,7 +59,22 @@ function StubButton({ icon, title, comingSoon }) {
58
59
  * its closed state. The dropdown portals to <body>, so it is not clipped by
59
60
  * the editor's `overflow: hidden` frame. */
60
61
  function ToolbarSelect({ icon: Icon, label, value, data, width, disabled, placeholder, searchable, renderOption, onChange, comingSoon, noMatchMessage }) {
61
- return (_jsx(Select, { size: "xs", w: width, "aria-label": label, title: disabled && comingSoon ? comingSoon(label) : label, data: data, value: value, placeholder: placeholder, disabled: disabled, searchable: searchable, renderOption: renderOption, nothingFoundMessage: searchable ? noMatchMessage : undefined, allowDeselect: false, leftSection: _jsx(Icon, { size: 15, stroke: ICON_STROKE }), comboboxProps: { withinPortal: true, position: 'bottom-start', middlewares: { flip: true, shift: true } }, classNames: { input: 'likhari-toolbar-select-input', dropdown: 'likhari-toolbar-select-dropdown' }, onChange: (v) => v && onChange?.(v) }));
62
+ const portalTarget = usePortalTarget();
63
+ return (_jsx(Select, { size: "xs", w: width, "aria-label": label, title: disabled && comingSoon ? comingSoon(label) : label, data: data, value: value, placeholder: placeholder, disabled: disabled, searchable: searchable, renderOption: renderOption, nothingFoundMessage: searchable ? noMatchMessage : undefined, allowDeselect: false, leftSection: _jsx(Icon, { size: 15, stroke: ICON_STROKE }), comboboxProps: { withinPortal: true, position: 'bottom-start', middlewares: { flip: true, shift: true }, portalProps: { target: portalTarget } }, classNames: { input: 'likhari-toolbar-select-input', dropdown: 'likhari-toolbar-select-dropdown' }, onChange: (v) => v && onChange?.(v) }));
64
+ }
65
+ const EMPTY_OVERFLOW = new Set();
66
+ function setsEqual(a, b) {
67
+ if (a.size !== b.size)
68
+ return false;
69
+ for (const v of a)
70
+ if (!b.has(v))
71
+ return false;
72
+ return true;
73
+ }
74
+ /** Renders a movable group's items as normal inline toolbar buttons (used
75
+ * both in the real toolbar and in the hidden width-measuring clone). */
76
+ function MovableGroupButtons({ group }) {
77
+ return (_jsx("div", { className: "likhari-toolbar-group", "data-group-key": group.key, children: group.items.map((item) => (_jsx(ToolbarButton, { icon: item.icon, title: item.label, active: item.active, onClick: item.onClick }, item.key))) }));
62
78
  }
63
79
  function OverflowItem({ icon: Icon, label, active, onClick, }) {
64
80
  return (_jsx(Menu.Item, { leftSection: _jsx(Icon, { size: ICON_SIZE, stroke: ICON_STROKE }), "data-active": active ? 'true' : 'false', className: "likhari-overflow-item",
@@ -78,6 +94,16 @@ export function Toolbar({ config, onSave, isDirty, showSave, fontOptions = DEFAU
78
94
  const [editor] = useLexicalComposerContext();
79
95
  const [state, setState] = useState(INITIAL_STATE);
80
96
  const strings = useStrings(locale);
97
+ const portalTarget = usePortalTarget();
98
+ // Responsive "priority+" overflow: the toolbar's outer container and a
99
+ // hidden nowrap clone (rendered with every movable group forced inline)
100
+ // are both measured; whenever the clone's natural width exceeds the
101
+ // container's available width, the lowest-priority movable groups are
102
+ // moved into the "..." menu, lowest-priority first, until what remains
103
+ // inline fits (or there's nothing left to move).
104
+ const toolbarContainerRef = useRef(null);
105
+ const toolbarMeasureRef = useRef(null);
106
+ const [overflowGroupKeys, setOverflowGroupKeys] = useState(EMPTY_OVERFLOW);
81
107
  const updateToolbar = useCallback(() => {
82
108
  editor.getEditorState().read(() => {
83
109
  const selection = $getSelection();
@@ -373,7 +399,112 @@ export function Toolbar({ config, onSave, isDirty, showSave, fontOptions = DEFAU
373
399
  const showAlignGroup = config.alignment.start || config.alignment.center || config.alignment.justify || config.alignment.left || config.alignment.right;
374
400
  const showStubInsertGroup = config.images.linked || config.images.embedded || config.poetry.enabled;
375
401
  const showLanguageGroup = config.language.autocorrect || config.language.textCleanup || config.language.spellCheck;
376
- const showOverflowMenu = fmt.strikethrough || fmt.superscript || fmt.subscript || fmt.caseTransforms || fmt.clearFormatting || config.indent;
402
+ // The "script & cleanup" and "indent/outdent" groups (UI spec §3.3) render
403
+ // as normal inline toolbar groups whenever there's room, and only move
404
+ // into the "..." overflow menu when the measured toolbar content doesn't
405
+ // fit the available width (see the ResizeObserver effect below) — this
406
+ // replaces the old always-on overflow menu, which was driven purely by
407
+ // these config flags regardless of actual space.
408
+ const scriptCleanupItems = [];
409
+ if (fmt.strikethrough) {
410
+ scriptCleanupItems.push({
411
+ key: 'strikethrough',
412
+ icon: IconStrikethrough,
413
+ label: strings.toolbar.strikethrough,
414
+ active: state.activeFormats.has('strikethrough'),
415
+ onClick: () => formatText('strikethrough'),
416
+ });
417
+ }
418
+ if (fmt.superscript) {
419
+ scriptCleanupItems.push({
420
+ key: 'superscript',
421
+ icon: IconSuperscript,
422
+ label: strings.toolbar.superscript,
423
+ active: state.activeFormats.has('superscript'),
424
+ onClick: () => formatText('superscript'),
425
+ });
426
+ }
427
+ if (fmt.subscript) {
428
+ scriptCleanupItems.push({
429
+ key: 'subscript',
430
+ icon: IconSubscript,
431
+ label: strings.toolbar.subscript,
432
+ active: state.activeFormats.has('subscript'),
433
+ onClick: () => formatText('subscript'),
434
+ });
435
+ }
436
+ if (fmt.caseTransforms) {
437
+ scriptCleanupItems.push({ key: 'upper', icon: IconLetterCaseUpper, label: strings.toolbar.uppercase, onClick: () => applyCaseTransform('upper') }, { key: 'lower', icon: IconLetterCaseLower, label: strings.toolbar.lowercase, onClick: () => applyCaseTransform('lower') }, { key: 'capitalize', icon: IconLetterCase, label: strings.toolbar.capitalize, onClick: () => applyCaseTransform('capitalize') });
438
+ }
439
+ if (fmt.clearFormatting) {
440
+ scriptCleanupItems.push({ key: 'clear', icon: IconClearFormatting, label: strings.toolbar.clearFormatting, onClick: clearFormatting });
441
+ }
442
+ const indentItems = config.indent
443
+ ? [
444
+ {
445
+ key: 'outdent',
446
+ icon: IconIndentDecrease,
447
+ label: strings.toolbar.outdent,
448
+ onClick: () => editor.dispatchCommand(OUTDENT_CONTENT_COMMAND, undefined),
449
+ },
450
+ {
451
+ key: 'indent',
452
+ icon: IconIndentIncrease,
453
+ label: strings.toolbar.indent,
454
+ onClick: () => editor.dispatchCommand(INDENT_CONTENT_COMMAND, undefined),
455
+ },
456
+ ]
457
+ : [];
458
+ // Low to high priority: the first group here is the first one pushed into
459
+ // overflow once content stops fitting (UI spec §3.3).
460
+ const movableGroups = [
461
+ { key: 'scriptCleanup', items: scriptCleanupItems },
462
+ { key: 'indentOutdent', items: indentItems },
463
+ ].filter((g) => g.items.length > 0);
464
+ const movableGroupKeys = useMemo(() => movableGroups.map((g) => g.key).join(','), [movableGroups]);
465
+ // Re-measure whenever the container is resized, or whenever the set of
466
+ // movable groups (and so the hidden clone's content) changes.
467
+ useLayoutEffect(() => {
468
+ const container = toolbarContainerRef.current;
469
+ const measure = toolbarMeasureRef.current;
470
+ if (!container || !measure)
471
+ return;
472
+ // Rough width of the "..." button itself (icon button + its gap) —
473
+ // only charged against the budget once something has actually moved
474
+ // into it, since it isn't rendered at all otherwise.
475
+ const OVERFLOW_BUTTON_WIDTH = 38;
476
+ const recompute = () => {
477
+ const available = container.clientWidth;
478
+ const total = measure.scrollWidth;
479
+ if (total <= available) {
480
+ setOverflowGroupKeys((prev) => (prev.size === 0 ? prev : EMPTY_OVERFLOW));
481
+ return;
482
+ }
483
+ const widths = new Map();
484
+ measure.querySelectorAll('[data-group-key]').forEach((el) => {
485
+ widths.set(el.dataset.groupKey, el.getBoundingClientRect().width);
486
+ });
487
+ let remaining = total;
488
+ const next = new Set();
489
+ for (const key of movableGroupKeys.split(',').filter(Boolean)) {
490
+ if (remaining <= available)
491
+ break;
492
+ const w = widths.get(key);
493
+ if (w === undefined)
494
+ continue;
495
+ remaining -= w;
496
+ next.add(key);
497
+ }
498
+ if (next.size > 0)
499
+ remaining += OVERFLOW_BUTTON_WIDTH;
500
+ setOverflowGroupKeys((prev) => (setsEqual(prev, next) ? prev : next));
501
+ };
502
+ recompute();
503
+ const ro = new ResizeObserver(recompute);
504
+ ro.observe(container);
505
+ ro.observe(measure);
506
+ return () => ro.disconnect();
507
+ });
377
508
  // Grouped for the dropdown when options declare groups (Latin / Urdu).
378
509
  const fontData = (() => {
379
510
  const data = [];
@@ -416,16 +547,46 @@ export function Toolbar({ config, onSave, isDirty, showSave, fontOptions = DEFAU
416
547
  ];
417
548
  const formattingValue = state.listType ?? state.blockType;
418
549
  const AlignIcon = ALIGN_ICONS[state.elementFormat] ?? IconAlignLeft;
419
- return (_jsxs("div", { className: "likhari-toolbar", role: "toolbar", "aria-label": strings.toolbar.ariaLabel, children: [showSave && (_jsx("div", { className: "likhari-toolbar-group", children: _jsx(ToolbarButton, { icon: IconDeviceFloppy, title: strings.toolbar.save, dirty: Boolean(isDirty), onClick: onSave }) })), config.history && (_jsxs("div", { className: "likhari-toolbar-group", children: [_jsx(ToolbarButton, { icon: IconArrowBackUp, title: strings.toolbar.undo, disabled: !state.canUndo, onClick: () => editor.dispatchCommand(UNDO_COMMAND, undefined) }), _jsx(ToolbarButton, { icon: IconArrowForwardUp, title: strings.toolbar.redo, disabled: !state.canRedo, onClick: () => editor.dispatchCommand(REDO_COMMAND, undefined) })] })), showFormattingGroup && (_jsx("div", { className: "likhari-toolbar-group", children: _jsx(ToolbarSelect, { icon: IconPilcrow, label: strings.toolbar.formattingLabel, width: 148, value: formattingValue, data: formattingOptions, comingSoon: strings.toolbar.comingSoon, onChange: withRefocus((v) => applyFormatting(v)) }) })), showInlineGroup && (_jsxs("div", { className: "likhari-toolbar-group", children: [fmt.bold && (_jsx(ToolbarButton, { icon: IconBold, title: strings.toolbar.bold, active: state.activeFormats.has('bold'), onClick: () => formatText('bold') })), fmt.italic && (_jsx(ToolbarButton, { icon: IconItalic, title: strings.toolbar.italic, active: state.activeFormats.has('italic'), onClick: () => formatText('italic') })), fmt.underline && (_jsx(ToolbarButton, { icon: IconUnderline, title: strings.toolbar.underline, active: state.activeFormats.has('underline'), onClick: () => formatText('underline') }))] })), (config.font.family || config.font.size) && (_jsxs("div", { className: "likhari-toolbar-group", children: [config.font.family && (_jsx(ToolbarSelect, { icon: IconTypography, label: strings.toolbar.fontFamily, width: 150, value: fontOptions.some((f) => f.family === shownFontFamily) ? shownFontFamily : null, placeholder: strings.toolbar.fontFamilyPlaceholder, data: fontData, searchable: true, noMatchMessage: strings.toolbar.noMatch, renderOption: ({ option }) => _jsx("span", { style: { fontFamily: option.value }, children: option.label }), onChange: withRefocus((v) => applyFont('font-family', v)) })), config.font.size && (_jsx(ToolbarSelect, { icon: IconTextSize, label: strings.toolbar.fontSize, width: 84, value: FONT_SIZES_PX.some((px) => `${px}px` === shownFontSize) ? shownFontSize : null, placeholder: strings.toolbar.fontSizePlaceholder, data: FONT_SIZES_PX.map((px) => ({ value: `${px}px`, label: String(px) })), onChange: withRefocus((v) => applyFont('font-size', v)) }))] })), showAlignGroup && (_jsx("div", { className: "likhari-toolbar-group", children: _jsx(ToolbarSelect, { icon: AlignIcon, label: strings.toolbar.alignment, width: 138, value: state.elementFormat || 'start', data: alignOptions, onChange: withRefocus((v) => formatElement(v)) }) })), config.links && (_jsx("div", { className: "likhari-toolbar-group", children: state.isLink ? (
420
- // Caret is in a link: the button opens a menu to see, edit or remove it.
421
- _jsxs(Menu, { position: "bottom-start", withinPortal: true, shadow: "sm", width: 240, onOpen: snapshotSelection, children: [_jsx(Menu.Target, { children: _jsx("button", { type: "button", className: "likhari-toolbar-button", "data-active": "true", "aria-pressed": "true", "aria-haspopup": "menu", "aria-label": strings.toolbar.linkOptions, title: strings.toolbar.linkOptions, onMouseDown: (e) => e.preventDefault(), children: _jsx(IconLink, { size: ICON_SIZE, stroke: ICON_STROKE }) }) }), _jsx(Menu.Dropdown, { children: _jsx(LinkMenuItems, { url: state.linkUrl, onEdit: () => {
422
- restoreSelection();
423
- openLinkDialog();
424
- }, onRemove: removeLink, strings: strings }) })] })) : (_jsx(ToolbarButton, { icon: IconLink, title: strings.toolbar.insertLink, onClick: openLinkDialog })) })), config.links && (_jsxs(Menu, { opened: linkContext !== null, onChange: (opened) => {
550
+ // The toolbar's "always inline" groups (UI spec §3.3's Desktop tier) —
551
+ // built as an array so the exact same elements can be rendered both in
552
+ // the visible toolbar and in the hidden width-measuring clone below,
553
+ // without duplicating the JSX. The link right-click context menu is kept
554
+ // out of this list: it's a zero-size, fixed-position anchor, not part of
555
+ // the toolbar's own layout width.
556
+ const fixedGroups = [
557
+ showSave && (_jsx("div", { className: "likhari-toolbar-group", children: _jsx(ToolbarButton, { icon: IconDeviceFloppy, title: strings.toolbar.save, dirty: Boolean(isDirty), onClick: onSave }) }, "save")),
558
+ config.history && (_jsxs("div", { className: "likhari-toolbar-group", children: [_jsx(ToolbarButton, { icon: IconArrowBackUp, title: strings.toolbar.undo, disabled: !state.canUndo, onClick: () => editor.dispatchCommand(UNDO_COMMAND, undefined) }), _jsx(ToolbarButton, { icon: IconArrowForwardUp, title: strings.toolbar.redo, disabled: !state.canRedo, onClick: () => editor.dispatchCommand(REDO_COMMAND, undefined) })] }, "history")),
559
+ showFormattingGroup && (_jsx("div", { className: "likhari-toolbar-group", children: _jsx(ToolbarSelect, { icon: IconPilcrow, label: strings.toolbar.formattingLabel, width: 148, value: formattingValue, data: formattingOptions, comingSoon: strings.toolbar.comingSoon, onChange: withRefocus((v) => applyFormatting(v)) }) }, "formatting")),
560
+ showInlineGroup && (_jsxs("div", { className: "likhari-toolbar-group", children: [fmt.bold && (_jsx(ToolbarButton, { icon: IconBold, title: strings.toolbar.bold, active: state.activeFormats.has('bold'), onClick: () => formatText('bold') })), fmt.italic && (_jsx(ToolbarButton, { icon: IconItalic, title: strings.toolbar.italic, active: state.activeFormats.has('italic'), onClick: () => formatText('italic') })), fmt.underline && (_jsx(ToolbarButton, { icon: IconUnderline, title: strings.toolbar.underline, active: state.activeFormats.has('underline'), onClick: () => formatText('underline') }))] }, "inline")),
561
+ (config.font.family || config.font.size) && (_jsxs("div", { className: "likhari-toolbar-group", children: [config.font.family && (_jsx(ToolbarSelect, { icon: IconTypography, label: strings.toolbar.fontFamily, width: 150, value: fontOptions.some((f) => f.family === shownFontFamily) ? shownFontFamily : null, placeholder: strings.toolbar.fontFamilyPlaceholder, data: fontData, searchable: true, noMatchMessage: strings.toolbar.noMatch, renderOption: ({ option }) => _jsx("span", { style: { fontFamily: option.value }, children: option.label }), onChange: withRefocus((v) => applyFont('font-family', v)) })), config.font.size && (_jsx(ToolbarSelect, { icon: IconTextSize, label: strings.toolbar.fontSize, width: 84, value: FONT_SIZES_PX.some((px) => `${px}px` === shownFontSize) ? shownFontSize : null, placeholder: strings.toolbar.fontSizePlaceholder, data: FONT_SIZES_PX.map((px) => ({ value: `${px}px`, label: String(px) })), onChange: withRefocus((v) => applyFont('font-size', v)) }))] }, "font")),
562
+ showAlignGroup && (_jsx("div", { className: "likhari-toolbar-group", children: _jsx(ToolbarSelect, { icon: AlignIcon, label: strings.toolbar.alignment, width: 138, value: state.elementFormat || 'start', data: alignOptions, onChange: withRefocus((v) => formatElement(v)) }) }, "align")),
563
+ config.links && (_jsx("div", { className: "likhari-toolbar-group", children: state.isLink ? (
564
+ // Caret is in a link: the button opens a menu to see, edit or remove it.
565
+ _jsxs(Menu, { position: "bottom-start", withinPortal: true, portalProps: { target: portalTarget }, shadow: "sm", width: 240, onOpen: snapshotSelection, children: [_jsx(Menu.Target, { children: _jsx("button", { type: "button", className: "likhari-toolbar-button", "data-active": "true", "aria-pressed": "true", "aria-haspopup": "menu", "aria-label": strings.toolbar.linkOptions, title: strings.toolbar.linkOptions, onMouseDown: (e) => e.preventDefault(), children: _jsx(IconLink, { size: ICON_SIZE, stroke: ICON_STROKE }) }) }), _jsx(Menu.Dropdown, { children: _jsx(LinkMenuItems, { url: state.linkUrl, onEdit: () => {
566
+ restoreSelection();
567
+ openLinkDialog();
568
+ }, onRemove: removeLink, strings: strings }) })] })) : (_jsx(ToolbarButton, { icon: IconLink, title: strings.toolbar.insertLink, onClick: openLinkDialog })) }, "link")),
569
+ showStubInsertGroup && (_jsxs("div", { className: "likhari-toolbar-group likhari-toolbar-group--collapse-tablet", children: [(config.images.linked || config.images.embedded) && (_jsx(ToolbarButton, { icon: IconPhoto, title: strings.toolbar.insertImage, onClick: openImageDialog })), config.poetry.enabled && _jsx(StubButton, { icon: IconFeather, title: strings.toolbar.poetryBlocks, comingSoon: strings.toolbar.comingSoon })] }, "stubInsert")),
570
+ showLanguageGroup && (_jsxs("div", { className: "likhari-toolbar-group likhari-toolbar-group--collapse-tablet", children: [config.language.autocorrect && _jsx(StubButton, { icon: IconWand, title: strings.toolbar.autocorrect, comingSoon: strings.toolbar.comingSoon }), config.language.textCleanup && _jsx(StubButton, { icon: IconSparkles, title: strings.toolbar.textCleanup, comingSoon: strings.toolbar.comingSoon }), config.language.spellCheck && _jsx(StubButton, { icon: IconAbc, title: strings.toolbar.spellChecker, comingSoon: strings.toolbar.comingSoon })] }, "language")),
571
+ ];
572
+ return (_jsxs("div", { className: "likhari-toolbar", role: "toolbar", "aria-label": strings.toolbar.ariaLabel, ref: toolbarContainerRef, children: [fixedGroups, movableGroups
573
+ .filter((group) => !overflowGroupKeys.has(group.key))
574
+ .map((group) => (_jsx(MovableGroupButtons, { group: group }, group.key))), overflowGroupKeys.size > 0 && (_jsxs(Menu, { position: "bottom-end", withinPortal: true, portalProps: { target: portalTarget }, shadow: "sm", width: 210, closeOnItemClick: true, onOpen: snapshotSelection, children: [_jsx(Menu.Target, { children: _jsx("button", { type: "button", className: "likhari-toolbar-button", "aria-label": strings.toolbar.moreFormatting, title: strings.toolbar.moreFormatting, onMouseDown: (e) => e.preventDefault(), children: _jsx(IconDots, { size: ICON_SIZE, stroke: ICON_STROKE }) }) }), _jsx(Menu.Dropdown, { children: movableGroups
575
+ .filter((group) => overflowGroupKeys.has(group.key))
576
+ .flatMap((group) => group.items.map((item) => (_jsx(OverflowItem, { icon: item.icon, label: item.label, active: item.active, onClick: runOverflowAction(item.onClick) }, item.key)))) })] })), config.links && (_jsxs(Menu, { opened: linkContext !== null, onChange: (opened) => {
425
577
  if (!opened)
426
578
  setLinkContext(null);
427
- }, position: "bottom-start", withinPortal: true, shadow: "sm", width: 240, children: [_jsx(Menu.Target, { children: _jsx("span", { "aria-hidden": "true", style: { position: 'fixed', left: linkContext?.x ?? -9999, top: linkContext?.y ?? -9999, width: 1, height: 1, pointerEvents: 'none' } }) }), _jsx(Menu.Dropdown, { children: _jsx(LinkMenuItems, { url: linkContext?.url ?? '', onEdit: () => {
579
+ }, position: "bottom-start", withinPortal: true, portalProps: { target: portalTarget }, shadow: "sm", width: 240, children: [_jsx(Menu.Target, { children: _jsx("span", { "aria-hidden": "true", style: { position: 'fixed', left: linkContext?.x ?? -9999, top: linkContext?.y ?? -9999, width: 1, height: 1, pointerEvents: 'none' } }) }), _jsx(Menu.Dropdown, { children: _jsx(LinkMenuItems, { url: linkContext?.url ?? '', onEdit: () => {
428
580
  restoreSelection();
429
581
  openLinkDialog();
430
- }, onRemove: removeLink, strings: strings }) })] })), showStubInsertGroup && (_jsxs("div", { className: "likhari-toolbar-group likhari-toolbar-group--collapse-tablet", children: [(config.images.linked || config.images.embedded) && (_jsx(ToolbarButton, { icon: IconPhoto, title: strings.toolbar.insertImage, onClick: openImageDialog })), config.poetry.enabled && _jsx(StubButton, { icon: IconFeather, title: strings.toolbar.poetryBlocks, comingSoon: strings.toolbar.comingSoon })] })), showLanguageGroup && (_jsxs("div", { className: "likhari-toolbar-group likhari-toolbar-group--collapse-tablet", children: [config.language.autocorrect && _jsx(StubButton, { icon: IconWand, title: strings.toolbar.autocorrect, comingSoon: strings.toolbar.comingSoon }), config.language.textCleanup && _jsx(StubButton, { icon: IconSparkles, title: strings.toolbar.textCleanup, comingSoon: strings.toolbar.comingSoon }), config.language.spellCheck && _jsx(StubButton, { icon: IconAbc, title: strings.toolbar.spellChecker, comingSoon: strings.toolbar.comingSoon })] })), showOverflowMenu && (_jsxs(Menu, { position: "bottom-end", withinPortal: true, shadow: "sm", width: 210, closeOnItemClick: true, onOpen: snapshotSelection, children: [_jsx(Menu.Target, { children: _jsx("button", { type: "button", className: "likhari-toolbar-button", "aria-label": strings.toolbar.moreFormatting, title: strings.toolbar.moreFormatting, onMouseDown: (e) => e.preventDefault(), children: _jsx(IconDots, { size: ICON_SIZE, stroke: ICON_STROKE }) }) }), _jsxs(Menu.Dropdown, { children: [fmt.strikethrough && (_jsx(OverflowItem, { icon: IconStrikethrough, label: strings.toolbar.strikethrough, active: state.activeFormats.has('strikethrough'), onClick: runOverflowAction(() => formatText('strikethrough')) })), fmt.superscript && (_jsx(OverflowItem, { icon: IconSuperscript, label: strings.toolbar.superscript, active: state.activeFormats.has('superscript'), onClick: runOverflowAction(() => formatText('superscript')) })), fmt.subscript && (_jsx(OverflowItem, { icon: IconSubscript, label: strings.toolbar.subscript, active: state.activeFormats.has('subscript'), onClick: runOverflowAction(() => formatText('subscript')) })), fmt.caseTransforms && (_jsxs(_Fragment, { children: [_jsx(OverflowItem, { icon: IconLetterCaseUpper, label: strings.toolbar.uppercase, onClick: runOverflowAction(() => applyCaseTransform('upper')) }), _jsx(OverflowItem, { icon: IconLetterCaseLower, label: strings.toolbar.lowercase, onClick: runOverflowAction(() => applyCaseTransform('lower')) }), _jsx(OverflowItem, { icon: IconLetterCase, label: strings.toolbar.capitalize, onClick: runOverflowAction(() => applyCaseTransform('capitalize')) })] })), fmt.clearFormatting && _jsx(OverflowItem, { icon: IconClearFormatting, label: strings.toolbar.clearFormatting, onClick: runOverflowAction(clearFormatting) }), config.indent && (_jsxs(_Fragment, { children: [_jsx(OverflowItem, { icon: IconIndentDecrease, label: strings.toolbar.outdent, onClick: runOverflowAction(() => editor.dispatchCommand(OUTDENT_CONTENT_COMMAND, undefined)) }), _jsx(OverflowItem, { icon: IconIndentIncrease, label: strings.toolbar.indent, onClick: runOverflowAction(() => editor.dispatchCommand(INDENT_CONTENT_COMMAND, undefined)) })] }))] })] })), (config.images.linked || config.images.embedded) && (_jsx(ImageDialog, { mode: "insert", opened: imageDialogOpen, onSubmit: insertImage, onClose: closeImageDialog })), config.links && (_jsx(LinkDialog, { opened: linkDialogOpen, initialUrl: state.linkUrl, showTextField: linkNeedsText, onSubmit: applyLink, onRemove: state.isLink ? removeLink : undefined, onClose: closeLinkDialog }))] }));
582
+ }, onRemove: removeLink, strings: strings }) })] })), _jsxs("div", { "aria-hidden": "true", ref: toolbarMeasureRef, style: {
583
+ position: 'absolute',
584
+ visibility: 'hidden',
585
+ pointerEvents: 'none',
586
+ top: 0,
587
+ left: 0,
588
+ display: 'flex',
589
+ flexWrap: 'nowrap',
590
+ gap: 8,
591
+ }, children: [fixedGroups, movableGroups.map((group) => (_jsx(MovableGroupButtons, { group: group }, group.key)))] }), (config.images.linked || config.images.embedded) && (_jsx(ImageDialog, { mode: "insert", opened: imageDialogOpen, onSubmit: insertImage, onClose: closeImageDialog })), config.links && (_jsx(LinkDialog, { opened: linkDialogOpen, initialUrl: state.linkUrl, showTextField: linkNeedsText, onSubmit: applyLink, onRemove: state.isLink ? removeLink : undefined, onClose: closeLinkDialog }))] }));
431
592
  }
@@ -1 +1 @@
1
- {"version":3,"file":"ImageComponent.d.ts","sourceRoot":"","sources":["../../src/image/ImageComponent.tsx"],"names":[],"mappings":"AAKA,OAAO,EASL,KAAK,OAAO,EACb,MAAM,SAAS,CAAC;AAMjB,OAAO,KAAK,EAAE,aAAa,EAAa,MAAM,aAAa,CAAC;AAU5D,UAAU,mBAAmB;IAC3B,OAAO,EAAE,OAAO,CAAC;IACjB,GAAG,EAAE,MAAM,CAAC;IACZ,OAAO,EAAE,MAAM,CAAC;IAChB,OAAO,EAAE,MAAM,GAAG,IAAI,CAAC;IACvB,QAAQ,EAAE,aAAa,CAAC;IACxB,yFAAyF;IACzF,KAAK,EAAE,MAAM,GAAG,IAAI,CAAC;IACrB,MAAM,EAAE,MAAM,GAAG,IAAI,CAAC;CACvB;AAED;;;;;;;GAOG;AACH,wBAAgB,cAAc,CAAC,EAAE,OAAO,EAAE,GAAG,EAAE,OAAO,EAAE,OAAO,EAAE,QAAQ,EAAE,KAAK,EAAE,MAAM,EAAE,EAAE,mBAAmB,+BA0P9G"}
1
+ {"version":3,"file":"ImageComponent.d.ts","sourceRoot":"","sources":["../../src/image/ImageComponent.tsx"],"names":[],"mappings":"AAKA,OAAO,EASL,KAAK,OAAO,EACb,MAAM,SAAS,CAAC;AAOjB,OAAO,KAAK,EAAE,aAAa,EAAa,MAAM,aAAa,CAAC;AAU5D,UAAU,mBAAmB;IAC3B,OAAO,EAAE,OAAO,CAAC;IACjB,GAAG,EAAE,MAAM,CAAC;IACZ,OAAO,EAAE,MAAM,CAAC;IAChB,OAAO,EAAE,MAAM,GAAG,IAAI,CAAC;IACvB,QAAQ,EAAE,aAAa,CAAC;IACxB,yFAAyF;IACzF,KAAK,EAAE,MAAM,GAAG,IAAI,CAAC;IACrB,MAAM,EAAE,MAAM,GAAG,IAAI,CAAC;CACvB;AAED;;;;;;;GAOG;AACH,wBAAgB,cAAc,CAAC,EAAE,OAAO,EAAE,GAAG,EAAE,OAAO,EAAE,OAAO,EAAE,QAAQ,EAAE,KAAK,EAAE,MAAM,EAAE,EAAE,mBAAmB,+BA4P9G"}
@@ -9,6 +9,7 @@ import { ImageCropDialog } from './ImageCropDialog.js';
9
9
  import { ImageDialog } from './ImageDialog.js';
10
10
  import { useImageOptions } from './ImageOptionsContext.js';
11
11
  import { useUiStrings } from '../i18n/useStrings.js';
12
+ import { usePortalTarget } from '../PortalTargetContext.js';
12
13
  import { dataUrlBytes, fetchImageAsDataUrl } from './imageEdit.js';
13
14
  // A type-only import of ImageNode (ImageNode itself imports this file), so the
14
15
  // node is recognised by its type string rather than `instanceof`.
@@ -28,6 +29,7 @@ export function ImageComponent({ nodeKey, src, altText, caption, linkType, width
28
29
  const [editor] = useLexicalComposerContext();
29
30
  const options = useImageOptions();
30
31
  const strings = useUiStrings();
32
+ const portalTarget = usePortalTarget();
31
33
  const [isSelected, setSelected, clearSelection] = useLexicalNodeSelection(nodeKey);
32
34
  const imageRef = useRef(null);
33
35
  const figureRef = useRef(null);
@@ -159,7 +161,7 @@ export function ImageComponent({ nodeKey, src, altText, caption, linkType, width
159
161
  }, children: [_jsxs("span", { className: "likhari-image-wrap", children: [_jsx("img", { ref: imageRef, className: "likhari-image", src: src, alt: altText, width: shownWidth, draggable: false }), isSelected && linkType === 'embedded' && (_jsx("span", { className: "likhari-image-resize-handle", role: "separator", "aria-label": strings.imageMenu.resizeImage, onPointerDown: onResizeDown, onPointerMove: onResizeMove, onPointerUp: onResizeUp, onPointerCancel: onResizeUp }))] }), caption && _jsx("figcaption", { className: "likhari-image-caption", children: caption }), _jsxs(Menu, { opened: menu !== null, onChange: (opened) => {
160
162
  if (!opened)
161
163
  setMenu(null);
162
- }, position: "bottom-start", withinPortal: true, shadow: "sm", width: 200, children: [_jsx(Menu.Target, { children: _jsx("span", { "aria-hidden": "true", style: { position: 'fixed', left: menu?.x ?? -9999, top: menu?.y ?? -9999, width: 1, height: 1, pointerEvents: 'none' } }) }), _jsxs(Menu.Dropdown, { children: [_jsx(Menu.Item, { leftSection: _jsx(IconPhotoEdit, { size: 16, stroke: 1.75 }), onClick: () => setDialog({}), children: strings.imageMenu.editImage }), linkType === 'linked' && options.allowEmbedded && (_jsx(Menu.Item, { leftSection: _jsx(IconPhotoDown, { size: 16, stroke: 1.75 }), onClick: () => void convertToEmbedded(), children: strings.imageMenu.convertToEmbedded })), linkType === 'embedded' && (_jsx(Menu.Item, { leftSection: _jsx(IconCrop, { size: 16, stroke: 1.75 }), onClick: () => setCropDialogOpen(true), children: strings.imageMenu.cropResize })), options.allowCaption && (_jsx(Menu.Item, { leftSection: _jsx(IconTextCaption, { size: 16, stroke: 1.75 }), onClick: () => setDialog({ intent: 'caption' }), children: caption ? strings.imageMenu.editCaption : strings.imageMenu.addCaption })), options.allowCaption && caption && (_jsx(Menu.Item, { leftSection: _jsx(IconX, { size: 16, stroke: 1.75 }), onClick: () => updateNode((node) => node.setCaption(null)), children: strings.imageMenu.removeCaption })), _jsx(Menu.Divider, {}), _jsx(Menu.Item, { color: "red", leftSection: _jsx(IconTrash, { size: 16, stroke: 1.75 }), onClick: removeImage, children: strings.imageMenu.deleteImage })] })] }), _jsx(ImageDialog, { mode: "edit", opened: dialog !== null, initial: { src, altText, caption, linkType }, intent: dialog?.intent, onSubmit: applyEdit, onClose: () => {
164
+ }, position: "bottom-start", withinPortal: true, portalProps: { target: portalTarget }, shadow: "sm", width: 200, children: [_jsx(Menu.Target, { children: _jsx("span", { "aria-hidden": "true", style: { position: 'fixed', left: menu?.x ?? -9999, top: menu?.y ?? -9999, width: 1, height: 1, pointerEvents: 'none' } }) }), _jsxs(Menu.Dropdown, { children: [_jsx(Menu.Item, { leftSection: _jsx(IconPhotoEdit, { size: 16, stroke: 1.75 }), onClick: () => setDialog({}), children: strings.imageMenu.editImage }), linkType === 'linked' && options.allowEmbedded && (_jsx(Menu.Item, { leftSection: _jsx(IconPhotoDown, { size: 16, stroke: 1.75 }), onClick: () => void convertToEmbedded(), children: strings.imageMenu.convertToEmbedded })), linkType === 'embedded' && (_jsx(Menu.Item, { leftSection: _jsx(IconCrop, { size: 16, stroke: 1.75 }), onClick: () => setCropDialogOpen(true), children: strings.imageMenu.cropResize })), options.allowCaption && (_jsx(Menu.Item, { leftSection: _jsx(IconTextCaption, { size: 16, stroke: 1.75 }), onClick: () => setDialog({ intent: 'caption' }), children: caption ? strings.imageMenu.editCaption : strings.imageMenu.addCaption })), options.allowCaption && caption && (_jsx(Menu.Item, { leftSection: _jsx(IconX, { size: 16, stroke: 1.75 }), onClick: () => updateNode((node) => node.setCaption(null)), children: strings.imageMenu.removeCaption })), _jsx(Menu.Divider, {}), _jsx(Menu.Item, { color: "red", leftSection: _jsx(IconTrash, { size: 16, stroke: 1.75 }), onClick: removeImage, children: strings.imageMenu.deleteImage })] })] }), _jsx(ImageDialog, { mode: "edit", opened: dialog !== null, initial: { src, altText, caption, linkType }, intent: dialog?.intent, onSubmit: applyEdit, onClose: () => {
163
165
  setDialog(null);
164
166
  editor.focus();
165
167
  } }), linkType === 'embedded' && (_jsx(ImageCropDialog, { opened: cropDialogOpen, initial: { src, width, height }, onSubmit: applyCrop, onClose: () => {
@@ -1 +1 @@
1
- {"version":3,"file":"ImageCropDialog.d.ts","sourceRoot":"","sources":["../../src/image/ImageCropDialog.tsx"],"names":[],"mappings":"AAQA,MAAM,WAAW,oBAAoB;IACnC,GAAG,EAAE,MAAM,CAAC;IACZ,KAAK,EAAE,MAAM,GAAG,IAAI,CAAC;IACrB,MAAM,EAAE,MAAM,GAAG,IAAI,CAAC;CACvB;AAED,MAAM,WAAW,oBAAoB;IACnC,MAAM,EAAE,OAAO,CAAC;IAChB;oEACgE;IAChE,OAAO,EAAE;QAAE,GAAG,EAAE,MAAM,CAAC;QAAC,KAAK,EAAE,MAAM,GAAG,IAAI,CAAC;QAAC,MAAM,EAAE,MAAM,GAAG,IAAI,CAAA;KAAE,CAAC;IACtE,QAAQ,EAAE,CAAC,KAAK,EAAE,oBAAoB,KAAK,IAAI,CAAC;IAChD,OAAO,EAAE,MAAM,IAAI,CAAC;CACrB;AAqBD;;qEAEqE;AACrE,wBAAgB,eAAe,CAAC,EAAE,MAAM,EAAE,OAAO,EAAE,QAAQ,EAAE,OAAO,EAAE,EAAE,oBAAoB,+BA6J3F"}
1
+ {"version":3,"file":"ImageCropDialog.d.ts","sourceRoot":"","sources":["../../src/image/ImageCropDialog.tsx"],"names":[],"mappings":"AASA,MAAM,WAAW,oBAAoB;IACnC,GAAG,EAAE,MAAM,CAAC;IACZ,KAAK,EAAE,MAAM,GAAG,IAAI,CAAC;IACrB,MAAM,EAAE,MAAM,GAAG,IAAI,CAAC;CACvB;AAED,MAAM,WAAW,oBAAoB;IACnC,MAAM,EAAE,OAAO,CAAC;IAChB;oEACgE;IAChE,OAAO,EAAE;QAAE,GAAG,EAAE,MAAM,CAAC;QAAC,KAAK,EAAE,MAAM,GAAG,IAAI,CAAC;QAAC,MAAM,EAAE,MAAM,GAAG,IAAI,CAAA;KAAE,CAAC;IACtE,QAAQ,EAAE,CAAC,KAAK,EAAE,oBAAoB,KAAK,IAAI,CAAC;IAChD,OAAO,EAAE,MAAM,IAAI,CAAC;CACrB;AAqBD;;qEAEqE;AACrE,wBAAgB,eAAe,CAAC,EAAE,MAAM,EAAE,OAAO,EAAE,QAAQ,EAAE,OAAO,EAAE,EAAE,oBAAoB,+BAqK3F"}
@@ -6,6 +6,7 @@ import { dataUrlBytes, dataUrlToFile, fitDimension, mimeFromSrc } from './imageE
6
6
  import { ImageCropper } from './ImageCropper.js';
7
7
  import { useImageOptions } from './ImageOptionsContext.js';
8
8
  import { useUiStrings } from '../i18n/useStrings.js';
9
+ import { usePortalTarget } from '../PortalTargetContext.js';
9
10
  /** Natural pixel size of an image URL (for the aspect ratio and size presets). */
10
11
  function useNaturalSize(src) {
11
12
  const [size, setSize] = useState(null);
@@ -31,6 +32,7 @@ function useNaturalSize(src) {
31
32
  * or stored through the host's `onImageUpload` when there is one. */
32
33
  export function ImageCropDialog({ opened, initial, onSubmit, onClose }) {
33
34
  const strings = useUiStrings();
35
+ const portalTarget = usePortalTarget();
34
36
  const { maxSizeMB, onImageUpload } = useImageOptions();
35
37
  const [pixelSrc, setPixelSrc] = useState(null);
36
38
  const [width, setWidth] = useState(null);
@@ -102,7 +104,7 @@ export function ImageCropDialog({ opened, initial, onSubmit, onClose }) {
102
104
  setBusy(false);
103
105
  }
104
106
  };
105
- return (_jsx(Modal, { opened: opened, onClose: onClose, title: strings.imageCropDialog.title, centered: true, size: "lg", children: _jsx("form", { onSubmit: handleSubmit, children: _jsxs(Stack, { gap: "md", children: [_jsx(ImageCropper, { src: effectiveSrc, canReset: pixelSrc !== null, onApply: (edited) => {
107
+ return (_jsx(Modal, { opened: opened, onClose: onClose, title: strings.imageCropDialog.title, centered: true, size: "lg", portalProps: { target: portalTarget }, children: _jsx("form", { onSubmit: handleSubmit, children: _jsxs(Stack, { gap: "md", children: [_jsx(ImageCropper, { src: effectiveSrc, canReset: pixelSrc !== null, onApply: (edited) => {
106
108
  setPixelSrc(edited);
107
109
  resetSize();
108
110
  }, onReset: () => {
@@ -1 +1 @@
1
- {"version":3,"file":"ImageCropper.d.ts","sourceRoot":"","sources":["../../src/image/ImageCropper.tsx"],"names":[],"mappings":"AA+CA,MAAM,WAAW,iBAAiB;IAChC,4DAA4D;IAC5D,GAAG,EAAE,MAAM,CAAC;IACZ,kDAAkD;IAClD,OAAO,EAAE,CAAC,MAAM,EAAE,MAAM,KAAK,IAAI,CAAC;IAClC,uCAAuC;IACvC,QAAQ,EAAE,OAAO,CAAC;IAClB,OAAO,EAAE,MAAM,IAAI,CAAC;CACrB;AAED;;;0BAG0B;AAC1B,wBAAgB,YAAY,CAAC,EAC3B,GAAG,EACH,OAAO,EACP,QAAQ,EACR,OAAO,GACR,EAAE,iBAAiB,+BAyOnB"}
1
+ {"version":3,"file":"ImageCropper.d.ts","sourceRoot":"","sources":["../../src/image/ImageCropper.tsx"],"names":[],"mappings":"AAgDA,MAAM,WAAW,iBAAiB;IAChC,4DAA4D;IAC5D,GAAG,EAAE,MAAM,CAAC;IACZ,kDAAkD;IAClD,OAAO,EAAE,CAAC,MAAM,EAAE,MAAM,KAAK,IAAI,CAAC;IAClC,uCAAuC;IACvC,QAAQ,EAAE,OAAO,CAAC;IAClB,OAAO,EAAE,MAAM,IAAI,CAAC;CACrB;AAED;;;0BAG0B;AAC1B,wBAAgB,YAAY,CAAC,EAC3B,GAAG,EACH,OAAO,EACP,QAAQ,EACR,OAAO,GACR,EAAE,iBAAiB,+BA0OnB"}
@@ -4,6 +4,7 @@ import { ActionIcon, Alert, Button, Group, SegmentedControl, Stack, Text, Toolti
4
4
  import { IconCrop, IconFlipHorizontal, IconFlipVertical, IconRestore, IconRotate2, IconRotateClockwise2, } from "@tabler/icons-react";
5
5
  import { applyPixelOp } from "./imageEdit.js";
6
6
  import { MIN_CROP, moveRect, rectFromDrag, resizeRect, } from "./cropMath.js";
7
+ import { usePortalTarget } from "../PortalTargetContext.js";
7
8
  const ASPECTS = {
8
9
  free: null,
9
10
  "1:1": 1,
@@ -17,6 +18,7 @@ const clamp = (v) => Math.min(Math.max(v, 0), 1);
17
18
  * Each operation re-renders the pixels, so it needs an image the canvas may read
18
19
  * (an embedded image). */
19
20
  export function ImageCropper({ src, onApply, canReset, onReset, }) {
21
+ const portalTarget = usePortalTarget();
20
22
  const imgRef = useRef(null);
21
23
  const drag = useRef(null);
22
24
  const [rect, setRect] = useState(null);
@@ -92,7 +94,7 @@ export function ImageCropper({ src, onApply, canReset, onReset, }) {
92
94
  const selectionSize = rect && natural && rect.w > 0 && rect.h > 0
93
95
  ? `${Math.max(1, Math.round(rect.w * natural.width))} × ${Math.max(1, Math.round(rect.h * natural.height))} px`
94
96
  : null;
95
- return (_jsxs(Stack, { gap: "sm", children: [_jsxs(Group, { gap: "xs", children: [_jsx(Tooltip, { label: "Rotate left", children: _jsx(ActionIcon, { variant: "default", "aria-label": "Rotate left", disabled: busy, onClick: () => run({ type: "rotate", direction: "ccw" }), children: _jsx(IconRotate2, { size: 16 }) }) }), _jsx(Tooltip, { label: "Rotate right", children: _jsx(ActionIcon, { variant: "default", "aria-label": "Rotate right", disabled: busy, onClick: () => run({ type: "rotate", direction: "cw" }), children: _jsx(IconRotateClockwise2, { size: 16 }) }) }), _jsx(Tooltip, { label: "Flip horizontally", children: _jsx(ActionIcon, { variant: "default", "aria-label": "Flip horizontally", disabled: busy, onClick: () => run({ type: "flip", axis: "horizontal" }), children: _jsx(IconFlipHorizontal, { size: 16 }) }) }), _jsx(Tooltip, { label: "Flip vertically", children: _jsx(ActionIcon, { variant: "default", "aria-label": "Flip vertically", disabled: busy, onClick: () => run({ type: "flip", axis: "vertical" }), children: _jsx(IconFlipVertical, { size: 16 }) }) }), _jsx(Button, { ml: "auto", size: "xs", variant: "subtle", leftSection: _jsx(IconRestore, { size: 14 }), disabled: !canReset || busy, onClick: () => {
97
+ return (_jsxs(Stack, { gap: "sm", children: [_jsxs(Group, { gap: "xs", children: [_jsx(Tooltip, { label: "Rotate left", portalProps: { target: portalTarget }, children: _jsx(ActionIcon, { variant: "default", "aria-label": "Rotate left", disabled: busy, onClick: () => run({ type: "rotate", direction: "ccw" }), children: _jsx(IconRotate2, { size: 16 }) }) }), _jsx(Tooltip, { label: "Rotate right", portalProps: { target: portalTarget }, children: _jsx(ActionIcon, { variant: "default", "aria-label": "Rotate right", disabled: busy, onClick: () => run({ type: "rotate", direction: "cw" }), children: _jsx(IconRotateClockwise2, { size: 16 }) }) }), _jsx(Tooltip, { label: "Flip horizontally", portalProps: { target: portalTarget }, children: _jsx(ActionIcon, { variant: "default", "aria-label": "Flip horizontally", disabled: busy, onClick: () => run({ type: "flip", axis: "horizontal" }), children: _jsx(IconFlipHorizontal, { size: 16 }) }) }), _jsx(Tooltip, { label: "Flip vertically", portalProps: { target: portalTarget }, children: _jsx(ActionIcon, { variant: "default", "aria-label": "Flip vertically", disabled: busy, onClick: () => run({ type: "flip", axis: "vertical" }), children: _jsx(IconFlipVertical, { size: 16 }) }) }), _jsx(Button, { ml: "auto", size: "xs", variant: "subtle", leftSection: _jsx(IconRestore, { size: 14 }), disabled: !canReset || busy, onClick: () => {
96
98
  setRect(null);
97
99
  setError(null);
98
100
  onReset();
@@ -1 +1 @@
1
- {"version":3,"file":"ImageDialog.d.ts","sourceRoot":"","sources":["../../src/image/ImageDialog.tsx"],"names":[],"mappings":"AAOA,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,aAAa,CAAC;AAEjD,MAAM,WAAW,gBAAgB;IAC/B,GAAG,EAAE,MAAM,CAAC;IACZ,OAAO,EAAE,MAAM,CAAC;IAChB,wBAAwB;IACxB,OAAO,EAAE,MAAM,GAAG,IAAI,CAAC;IACvB,QAAQ,EAAE,aAAa,CAAC;IACxB;gFAC4E;IAC5E,aAAa,EAAE,OAAO,CAAC;CACxB;AAED,MAAM,WAAW,gBAAgB;IAC/B,qFAAqF;IACrF,IAAI,EAAE,QAAQ,GAAG,MAAM,CAAC;IACxB,MAAM,EAAE,OAAO,CAAC;IAChB,0CAA0C;IAC1C,OAAO,CAAC,EAAE;QAAE,GAAG,EAAE,MAAM,CAAC;QAAC,OAAO,EAAE,MAAM,CAAC;QAAC,OAAO,EAAE,MAAM,GAAG,IAAI,CAAC;QAAC,QAAQ,EAAE,aAAa,CAAA;KAAE,CAAC;IAC5F;4CACwC;IACxC,MAAM,CAAC,EAAE,SAAS,GAAG,SAAS,CAAC;IAC/B,QAAQ,EAAE,CAAC,KAAK,EAAE,gBAAgB,KAAK,IAAI,CAAC;IAC5C,OAAO,EAAE,MAAM,IAAI,CAAC;CACrB;AAaD;;;;;;GAMG;AACH,wBAAgB,WAAW,CAAC,EAAE,IAAI,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,EAAE,QAAQ,EAAE,OAAO,EAAE,EAAE,gBAAgB,+BAgQjG"}
1
+ {"version":3,"file":"ImageDialog.d.ts","sourceRoot":"","sources":["../../src/image/ImageDialog.tsx"],"names":[],"mappings":"AAQA,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,aAAa,CAAC;AAEjD,MAAM,WAAW,gBAAgB;IAC/B,GAAG,EAAE,MAAM,CAAC;IACZ,OAAO,EAAE,MAAM,CAAC;IAChB,wBAAwB;IACxB,OAAO,EAAE,MAAM,GAAG,IAAI,CAAC;IACvB,QAAQ,EAAE,aAAa,CAAC;IACxB;gFAC4E;IAC5E,aAAa,EAAE,OAAO,CAAC;CACxB;AAED,MAAM,WAAW,gBAAgB;IAC/B,qFAAqF;IACrF,IAAI,EAAE,QAAQ,GAAG,MAAM,CAAC;IACxB,MAAM,EAAE,OAAO,CAAC;IAChB,0CAA0C;IAC1C,OAAO,CAAC,EAAE;QAAE,GAAG,EAAE,MAAM,CAAC;QAAC,OAAO,EAAE,MAAM,CAAC;QAAC,OAAO,EAAE,MAAM,GAAG,IAAI,CAAC;QAAC,QAAQ,EAAE,aAAa,CAAA;KAAE,CAAC;IAC5F;4CACwC;IACxC,MAAM,CAAC,EAAE,SAAS,GAAG,SAAS,CAAC;IAC/B,QAAQ,EAAE,CAAC,KAAK,EAAE,gBAAgB,KAAK,IAAI,CAAC;IAC5C,OAAO,EAAE,MAAM,IAAI,CAAC;CACrB;AAaD;;;;;;GAMG;AACH,wBAAgB,WAAW,CAAC,EAAE,IAAI,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,EAAE,QAAQ,EAAE,OAAO,EAAE,EAAE,gBAAgB,+BAwQjG"}
@@ -6,6 +6,7 @@ import { ACCEPTED_IMAGE_TYPES, normalizeImageUrl, validateImageFile } from './im
6
6
  import { dataUrlBytes, dataUrlToFile, fetchImageAsDataUrl, mimeFromSrc } from './imageEdit.js';
7
7
  import { useImageOptions } from './ImageOptionsContext.js';
8
8
  import { useUiStrings } from '../i18n/useStrings.js';
9
+ import { usePortalTarget } from '../PortalTargetContext.js';
9
10
  function readAsDataUrl(file) {
10
11
  return new Promise((resolve, reject) => {
11
12
  const reader = new FileReader();
@@ -23,6 +24,7 @@ function readAsDataUrl(file) {
23
24
  */
24
25
  export function ImageDialog({ mode, opened, initial, intent, onSubmit, onClose }) {
25
26
  const strings = useUiStrings();
27
+ const portalTarget = usePortalTarget();
26
28
  const { allowLinked, allowEmbedded, allowCaption, maxSizeMB, onImageUpload, fetchImage } = useImageOptions();
27
29
  const editing = mode === 'edit' && initial !== undefined;
28
30
  const defaultSource = editing ? 'keep' : allowLinked ? 'linked' : 'embedded';
@@ -165,7 +167,7 @@ export function ImageDialog({ mode, opened, initial, intent, onSubmit, onClose }
165
167
  }
166
168
  };
167
169
  const convertButton = (_jsx(Button, { size: "xs", variant: "light", leftSection: _jsx(IconPhotoDown, { size: 14 }), loading: converting, disabled: !baseSrc, onClick: () => convert(baseSrc), children: strings.imageDialog.convertButton }));
168
- return (_jsx(Modal, { opened: opened, onClose: onClose, title: editing ? strings.imageDialog.titleEdit : strings.imageDialog.titleInsert, centered: true, size: "sm", children: _jsx("form", { onSubmit: handleSubmit, children: _jsxs(Stack, { gap: "sm", children: [(editing || (allowLinked && allowEmbedded)) && (_jsx(SegmentedControl, { fullWidth: true, value: source, onChange: (v) => changeSource(v), data: [
170
+ return (_jsx(Modal, { opened: opened, onClose: onClose, title: editing ? strings.imageDialog.titleEdit : strings.imageDialog.titleInsert, centered: true, size: "sm", portalProps: { target: portalTarget }, children: _jsx("form", { onSubmit: handleSubmit, children: _jsxs(Stack, { gap: "sm", children: [(editing || (allowLinked && allowEmbedded)) && (_jsx(SegmentedControl, { fullWidth: true, value: source, onChange: (v) => changeSource(v), data: [
169
171
  ...(editing ? [{ value: 'keep', label: strings.imageDialog.currentImage }] : []),
170
172
  ...(allowLinked ? [{ value: 'linked', label: strings.imageDialog.fromUrl }] : []),
171
173
  ...(allowEmbedded ? [{ value: 'embedded', label: strings.imageDialog.upload }] : []),
@@ -4,7 +4,10 @@ import './editor.css';
4
4
  export interface EditorThemeProviderProps {
5
5
  theme?: MantineThemeOverride;
6
6
  colorScheme?: 'light' | 'dark';
7
+ /** Overrides both Mantine's primary color and the `--editor-accent` /
8
+ * `--editor-accent-soft` CSS variables — see EditorRootProps.accentColor. */
9
+ accentColor?: string;
7
10
  children: React.ReactNode;
8
11
  }
9
- export declare function EditorThemeProvider({ theme, colorScheme, children }: EditorThemeProviderProps): import("react").JSX.Element;
12
+ export declare function EditorThemeProvider({ theme, colorScheme, accentColor, children }: EditorThemeProviderProps): import("react").JSX.Element;
10
13
  //# sourceMappingURL=EditorThemeProvider.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"EditorThemeProvider.d.ts","sourceRoot":"","sources":["../../src/theme/EditorThemeProvider.tsx"],"names":[],"mappings":"AAAA,OAAO,EAAmB,KAAK,oBAAoB,EAAE,MAAM,eAAe,CAAC;AAC3E,OAAO,0BAA0B,CAAC;AAGlC,OAAO,cAAc,CAAC;AAsBtB,MAAM,WAAW,wBAAwB;IACvC,KAAK,CAAC,EAAE,oBAAoB,CAAC;IAC7B,WAAW,CAAC,EAAE,OAAO,GAAG,MAAM,CAAC;IAC/B,QAAQ,EAAE,KAAK,CAAC,SAAS,CAAC;CAC3B;AAED,wBAAgB,mBAAmB,CAAC,EAAE,KAAK,EAAE,WAAW,EAAE,QAAQ,EAAE,EAAE,wBAAwB,+BAa7F"}
1
+ {"version":3,"file":"EditorThemeProvider.d.ts","sourceRoot":"","sources":["../../src/theme/EditorThemeProvider.tsx"],"names":[],"mappings":"AACA,OAAO,EAAmB,KAAK,oBAAoB,EAAE,MAAM,eAAe,CAAC;AAC3E,OAAO,0BAA0B,CAAC;AAIlC,OAAO,cAAc,CAAC;AAsBtB,MAAM,WAAW,wBAAwB;IACvC,KAAK,CAAC,EAAE,oBAAoB,CAAC;IAC7B,WAAW,CAAC,EAAE,OAAO,GAAG,MAAM,CAAC;IAC/B;iFAC6E;IAC7E,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,QAAQ,EAAE,KAAK,CAAC,SAAS,CAAC;CAC3B;AAED,wBAAgB,mBAAmB,CAAC,EAAE,KAAK,EAAE,WAAW,EAAE,WAAW,EAAE,QAAQ,EAAE,EAAE,wBAAwB,+BAuC1G"}
@@ -1,8 +1,10 @@
1
1
  import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { useMemo } from 'react';
2
3
  import { MantineProvider } from '@mantine/core';
3
4
  import '@mantine/core/styles.css';
4
5
  import { buildThemeCss } from '@inshapardaz/likhari-core';
5
6
  import { defaultMantineTheme } from './mantineTheme.js';
7
+ import { generateAccentCssVars, generateAccentShades } from './accentColor.js';
6
8
  import './editor.css';
7
9
  let cssInjected = false;
8
10
  // Scopes the --editor-* variables to the wrapper div below, which is what
@@ -21,8 +23,28 @@ function useThemeCssInjection() {
21
23
  cssInjected = true;
22
24
  }
23
25
  }
24
- export function EditorThemeProvider({ theme, colorScheme, children }) {
26
+ export function EditorThemeProvider({ theme, colorScheme, accentColor, children }) {
25
27
  useThemeCssInjection();
28
+ // Mantine wants a 10-shade array; a host only supplies one color, so a
29
+ // small local helper (accentColor.ts) mixes it towards white/black for
30
+ // the rest of the ramp. If `theme` is ALSO supplied, its own `colors`
31
+ // still wins for anything it explicitly sets (shallow-merged per color
32
+ // key) — the accent override alone isn't worth a generic deep-merge here.
33
+ const resolvedTheme = useMemo(() => {
34
+ const shades = accentColor ? generateAccentShades(accentColor) : null;
35
+ const base = shades ? { ...defaultMantineTheme, colors: { ...defaultMantineTheme.colors, likhariAccent: shades } } : defaultMantineTheme;
36
+ if (!theme)
37
+ return base;
38
+ return { ...base, ...theme, colors: { ...base.colors, ...theme.colors } };
39
+ }, [accentColor, theme]);
40
+ // Inline style wins over the injected stylesheet rule (same element, same
41
+ // specificity otherwise) — only set when accentColor is actually given,
42
+ // so the default static tokens keep applying via the stylesheet the rest
43
+ // of the time.
44
+ const accentVars = accentColor ? generateAccentCssVars(accentColor, colorScheme === 'dark' ? 'dark' : 'light') : null;
45
+ const accentStyle = accentVars
46
+ ? { '--editor-accent': accentVars.accent, '--editor-accent-soft': accentVars.accentSoft }
47
+ : {};
26
48
  return (
27
49
  // display: contents keeps this wrapper out of the layout tree — CSS
28
50
  // custom properties still cascade to children through it (inheritance
@@ -30,5 +52,5 @@ export function EditorThemeProvider({ theme, colorScheme, children }) {
30
52
  // element and the host's actual parent, so EditorRoot's `height` prop
31
53
  // (e.g. '100%') resolves against the host's container as if this div
32
54
  // weren't there at all.
33
- _jsx("div", { className: "likhari-theme-scope", "data-editor-color-scheme": colorScheme, style: { display: 'contents' }, children: _jsx(MantineProvider, { theme: theme ?? defaultMantineTheme, children: children }) }));
55
+ _jsx("div", { className: "likhari-theme-scope", "data-editor-color-scheme": colorScheme, style: { display: 'contents', ...accentStyle }, children: _jsx(MantineProvider, { theme: resolvedTheme, children: children }) }));
34
56
  }
@@ -0,0 +1,43 @@
1
+ /**
2
+ * Derives Mantine's 10-shade color array and the editor's own
3
+ * `--editor-accent` / `--editor-accent-soft` pair from a single
4
+ * host-supplied accent color (EditorRootProps.accentColor). No color
5
+ * library needed — a few lines of hex parsing plus RGB lerp is enough for a
6
+ * usable (not professionally calibrated) ramp.
7
+ */
8
+ interface Rgb {
9
+ r: number;
10
+ g: number;
11
+ b: number;
12
+ }
13
+ /** Parses a `#rgb` or `#rrggbb` hex color. Returns null for anything else —
14
+ * callers fall back to the static default tokens rather than throwing on a
15
+ * bad host-supplied value. */
16
+ export declare function hexToRgb(hex: string): Rgb | null;
17
+ export declare function rgbToHex({ r, g, b }: Rgb): string;
18
+ /** Linear-interpolates two colors; t=0 -> a, t=1 -> b. */
19
+ export declare function mix(a: Rgb, b: Rgb, t: number): Rgb;
20
+ /** Mantine's color-tuple shape: exactly 10 shades. */
21
+ export type AccentShades = readonly [string, string, string, string, string, string, string, string, string, string];
22
+ /**
23
+ * A 10-shade Mantine color array for `theme.colors.likhariAccent`, mixing
24
+ * the base color towards white for the lighter shades (0-4ish) and towards
25
+ * black for the darker ones (6-9ish) — shade 6 (Mantine's default "filled"
26
+ * shade) is the exact input color, matching what the static
27
+ * `likhariAccent` ramp already did for the default tokens.
28
+ */
29
+ export declare function generateAccentShades(hex: string): AccentShades | null;
30
+ /**
31
+ * The editor canvas/toolbar's own `--editor-accent` / `--editor-accent-soft`
32
+ * pair for a host-supplied accent color, mirroring the relationship between
33
+ * `LIGHT_TOKENS`/`DARK_TOKENS`'s `accent`/`accentSoft` (packages/core/src/theme/tokens.ts):
34
+ * light mode keeps the accent as given and derives a light tint for "soft";
35
+ * dark mode lightens the accent itself (for contrast against a dark canvas)
36
+ * and derives a dark tint for "soft".
37
+ */
38
+ export declare function generateAccentCssVars(hex: string, mode: 'light' | 'dark'): {
39
+ accent: string;
40
+ accentSoft: string;
41
+ } | null;
42
+ export {};
43
+ //# sourceMappingURL=accentColor.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"accentColor.d.ts","sourceRoot":"","sources":["../../src/theme/accentColor.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AAEH,UAAU,GAAG;IACX,CAAC,EAAE,MAAM,CAAC;IACV,CAAC,EAAE,MAAM,CAAC;IACV,CAAC,EAAE,MAAM,CAAC;CACX;AAED;;8BAE8B;AAC9B,wBAAgB,QAAQ,CAAC,GAAG,EAAE,MAAM,GAAG,GAAG,GAAG,IAAI,CAehD;AAQD,wBAAgB,QAAQ,CAAC,EAAE,CAAC,EAAE,CAAC,EAAE,CAAC,EAAE,EAAE,GAAG,GAAG,MAAM,CAEjD;AAED,0DAA0D;AAC1D,wBAAgB,GAAG,CAAC,CAAC,EAAE,GAAG,EAAE,CAAC,EAAE,GAAG,EAAE,CAAC,EAAE,MAAM,GAAG,GAAG,CAMlD;AAKD,sDAAsD;AACtD,MAAM,MAAM,YAAY,GAAG,SAAS,CAAC,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,CAAC,CAAC;AAErH;;;;;;GAMG;AACH,wBAAgB,oBAAoB,CAAC,GAAG,EAAE,MAAM,GAAG,YAAY,GAAG,IAAI,CAQrE;AAED;;;;;;;GAOG;AACH,wBAAgB,qBAAqB,CAAC,GAAG,EAAE,MAAM,EAAE,IAAI,EAAE,OAAO,GAAG,MAAM,GAAG;IAAE,MAAM,EAAE,MAAM,CAAC;IAAC,UAAU,EAAE,MAAM,CAAA;CAAE,GAAG,IAAI,CAaxH"}
@@ -0,0 +1,84 @@
1
+ /**
2
+ * Derives Mantine's 10-shade color array and the editor's own
3
+ * `--editor-accent` / `--editor-accent-soft` pair from a single
4
+ * host-supplied accent color (EditorRootProps.accentColor). No color
5
+ * library needed — a few lines of hex parsing plus RGB lerp is enough for a
6
+ * usable (not professionally calibrated) ramp.
7
+ */
8
+ /** Parses a `#rgb` or `#rrggbb` hex color. Returns null for anything else —
9
+ * callers fall back to the static default tokens rather than throwing on a
10
+ * bad host-supplied value. */
11
+ export function hexToRgb(hex) {
12
+ const cleaned = hex.trim().replace(/^#/, '');
13
+ const full = cleaned.length === 3
14
+ ? cleaned
15
+ .split('')
16
+ .map((c) => c + c)
17
+ .join('')
18
+ : cleaned;
19
+ if (!/^[0-9a-fA-F]{6}$/.test(full))
20
+ return null;
21
+ return {
22
+ r: parseInt(full.slice(0, 2), 16),
23
+ g: parseInt(full.slice(2, 4), 16),
24
+ b: parseInt(full.slice(4, 6), 16),
25
+ };
26
+ }
27
+ function toHex(n) {
28
+ return Math.round(Math.min(255, Math.max(0, n)))
29
+ .toString(16)
30
+ .padStart(2, '0');
31
+ }
32
+ export function rgbToHex({ r, g, b }) {
33
+ return `#${toHex(r)}${toHex(g)}${toHex(b)}`;
34
+ }
35
+ /** Linear-interpolates two colors; t=0 -> a, t=1 -> b. */
36
+ export function mix(a, b, t) {
37
+ return {
38
+ r: a.r + (b.r - a.r) * t,
39
+ g: a.g + (b.g - a.g) * t,
40
+ b: a.b + (b.b - a.b) * t,
41
+ };
42
+ }
43
+ const WHITE = { r: 255, g: 255, b: 255 };
44
+ const BLACK = { r: 0, g: 0, b: 0 };
45
+ /**
46
+ * A 10-shade Mantine color array for `theme.colors.likhariAccent`, mixing
47
+ * the base color towards white for the lighter shades (0-4ish) and towards
48
+ * black for the darker ones (6-9ish) — shade 6 (Mantine's default "filled"
49
+ * shade) is the exact input color, matching what the static
50
+ * `likhariAccent` ramp already did for the default tokens.
51
+ */
52
+ export function generateAccentShades(hex) {
53
+ const base = hexToRgb(hex);
54
+ if (!base)
55
+ return null;
56
+ // How far to mix towards white (positive) or black (negative) per shade,
57
+ // relative to shade 6 = the exact input color.
58
+ const mixAmount = [0.88, 0.72, 0.56, 0.4, 0.22, 0.1, 0, -0.14, -0.28, -0.42];
59
+ const shades = mixAmount.map((t) => rgbToHex(mix(base, t >= 0 ? WHITE : BLACK, Math.abs(t))));
60
+ return shades;
61
+ }
62
+ /**
63
+ * The editor canvas/toolbar's own `--editor-accent` / `--editor-accent-soft`
64
+ * pair for a host-supplied accent color, mirroring the relationship between
65
+ * `LIGHT_TOKENS`/`DARK_TOKENS`'s `accent`/`accentSoft` (packages/core/src/theme/tokens.ts):
66
+ * light mode keeps the accent as given and derives a light tint for "soft";
67
+ * dark mode lightens the accent itself (for contrast against a dark canvas)
68
+ * and derives a dark tint for "soft".
69
+ */
70
+ export function generateAccentCssVars(hex, mode) {
71
+ const base = hexToRgb(hex);
72
+ if (!base)
73
+ return null;
74
+ if (mode === 'dark') {
75
+ return {
76
+ accent: rgbToHex(mix(base, WHITE, 0.45)),
77
+ accentSoft: rgbToHex(mix(base, BLACK, 0.75)),
78
+ };
79
+ }
80
+ return {
81
+ accent: hex,
82
+ accentSoft: rgbToHex(mix(base, WHITE, 0.88)),
83
+ };
84
+ }
@@ -19,6 +19,12 @@
19
19
  its own height so only the canvas below scrolls. */
20
20
  .likhari-toolbar {
21
21
  display: flex;
22
+ /* Containing block for the hidden width-measuring clone (Toolbar.tsx):
23
+ without this, its position: absolute would resolve against the
24
+ viewport instead, and its intentionally-unwrapped (possibly very wide)
25
+ content could inflate the host page's scroll area instead of being
26
+ clipped by .likhari-root's overflow: hidden. */
27
+ position: relative;
22
28
  flex-shrink: 0;
23
29
  align-items: center;
24
30
  gap: 8px;
@@ -33,6 +39,18 @@
33
39
  row-gap: 4px;
34
40
  }
35
41
 
42
+ /* Empty portal-anchor (EditorRoot.tsx) — exists only so Mantine's portaled
43
+ Menu/Modal/Select-combobox/Tooltip content has a DOM ancestor inside
44
+ .likhari-root/.likhari-theme-scope to inherit --editor-* vars and `dir`
45
+ from; it must take up no space and never intercept pointer events. */
46
+ .likhari-portal-target {
47
+ position: absolute;
48
+ width: 0;
49
+ height: 0;
50
+ overflow: visible;
51
+ pointer-events: none;
52
+ }
53
+
36
54
  .likhari-toolbar-group {
37
55
  display: flex;
38
56
  align-items: center;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@inshapardaz/likhari-react",
3
- "version": "0.1.6-dev.62",
3
+ "version": "0.1.6-dev.64",
4
4
  "description": "React implementation of the Likhari editor, built on @lexical/react.",
5
5
  "license": "MIT",
6
6
  "repository": {