@inshapardaz/likhari-react 0.1.6-dev.60 → 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.
Files changed (39) hide show
  1. package/dist/EditorRoot.d.ts +10 -1
  2. package/dist/EditorRoot.d.ts.map +1 -1
  3. package/dist/EditorRoot.js +14 -5
  4. package/dist/PortalTargetContext.d.ts +16 -0
  5. package/dist/PortalTargetContext.d.ts.map +1 -0
  6. package/dist/PortalTargetContext.js +18 -0
  7. package/dist/components/LinkDialog.d.ts.map +1 -1
  8. package/dist/components/LinkDialog.js +7 -3
  9. package/dist/components/Toolbar.d.ts +4 -1
  10. package/dist/components/Toolbar.d.ts.map +1 -1
  11. package/dist/components/Toolbar.js +193 -30
  12. package/dist/i18n/index.d.ts +4 -0
  13. package/dist/i18n/index.d.ts.map +1 -0
  14. package/dist/i18n/index.js +2 -0
  15. package/dist/i18n/strings.d.ts +158 -0
  16. package/dist/i18n/strings.d.ts.map +1 -0
  17. package/dist/i18n/strings.js +425 -0
  18. package/dist/i18n/useStrings.d.ts +10 -0
  19. package/dist/i18n/useStrings.d.ts.map +1 -0
  20. package/dist/i18n/useStrings.js +14 -0
  21. package/dist/image/ImageComponent.d.ts.map +1 -1
  22. package/dist/image/ImageComponent.js +7 -3
  23. package/dist/image/ImageCropDialog.d.ts.map +1 -1
  24. package/dist/image/ImageCropDialog.js +13 -6
  25. package/dist/image/ImageCropper.d.ts.map +1 -1
  26. package/dist/image/ImageCropper.js +3 -1
  27. package/dist/image/ImageDialog.d.ts.map +1 -1
  28. package/dist/image/ImageDialog.js +30 -16
  29. package/dist/index.d.ts +2 -0
  30. package/dist/index.d.ts.map +1 -1
  31. package/dist/index.js +1 -0
  32. package/dist/theme/EditorThemeProvider.d.ts +4 -1
  33. package/dist/theme/EditorThemeProvider.d.ts.map +1 -1
  34. package/dist/theme/EditorThemeProvider.js +24 -2
  35. package/dist/theme/accentColor.d.ts +43 -0
  36. package/dist/theme/accentColor.d.ts.map +1 -0
  37. package/dist/theme/accentColor.js +84 -0
  38. package/dist/theme/editor.css +18 -0
  39. package/package.json +1 -1
@@ -8,6 +8,8 @@ import { $getNodeByKey, $getSelection, $isNodeSelection, CLICK_COMMAND, COMMAND_
8
8
  import { ImageCropDialog } from './ImageCropDialog.js';
9
9
  import { ImageDialog } from './ImageDialog.js';
10
10
  import { useImageOptions } from './ImageOptionsContext.js';
11
+ import { useUiStrings } from '../i18n/useStrings.js';
12
+ import { usePortalTarget } from '../PortalTargetContext.js';
11
13
  import { dataUrlBytes, fetchImageAsDataUrl } from './imageEdit.js';
12
14
  // A type-only import of ImageNode (ImageNode itself imports this file), so the
13
15
  // node is recognised by its type string rather than `instanceof`.
@@ -26,6 +28,8 @@ const MIN_WIDTH = 32;
26
28
  export function ImageComponent({ nodeKey, src, altText, caption, linkType, width, height }) {
27
29
  const [editor] = useLexicalComposerContext();
28
30
  const options = useImageOptions();
31
+ const strings = useUiStrings();
32
+ const portalTarget = usePortalTarget();
29
33
  const [isSelected, setSelected, clearSelection] = useLexicalNodeSelection(nodeKey);
30
34
  const imageRef = useRef(null);
31
35
  const figureRef = useRef(null);
@@ -82,7 +86,7 @@ export function ImageComponent({ nodeKey, src, altText, caption, linkType, width
82
86
  try {
83
87
  const dataUrl = await fetchImageAsDataUrl(src, options.fetchImage);
84
88
  if (dataUrlBytes(dataUrl) > options.maxSizeMB * 1024 * 1024) {
85
- throw new Error(`Image is larger than ${options.maxSizeMB} MB`);
89
+ throw new Error(strings.imageDialog.errors.largerThan(options.maxSizeMB));
86
90
  }
87
91
  updateNode((node) => node.setSource(dataUrl, 'embedded'));
88
92
  }
@@ -154,10 +158,10 @@ export function ImageComponent({ nodeKey, src, altText, caption, linkType, width
154
158
  clearSelection();
155
159
  setSelected(true);
156
160
  setMenu({ x: e.clientX, y: e.clientY });
157
- }, 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": "Resize image", onPointerDown: onResizeDown, onPointerMove: onResizeMove, onPointerUp: onResizeUp, onPointerCancel: onResizeUp }))] }), caption && _jsx("figcaption", { className: "likhari-image-caption", children: caption }), _jsxs(Menu, { opened: menu !== null, onChange: (opened) => {
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) => {
158
162
  if (!opened)
159
163
  setMenu(null);
160
- }, 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: "Edit image\u2026" }), linkType === 'linked' && options.allowEmbedded && (_jsx(Menu.Item, { leftSection: _jsx(IconPhotoDown, { size: 16, stroke: 1.75 }), onClick: () => void convertToEmbedded(), children: "Convert to embedded image" })), linkType === 'embedded' && (_jsx(Menu.Item, { leftSection: _jsx(IconCrop, { size: 16, stroke: 1.75 }), onClick: () => setCropDialogOpen(true), children: "Crop & resize\u2026" })), options.allowCaption && (_jsx(Menu.Item, { leftSection: _jsx(IconTextCaption, { size: 16, stroke: 1.75 }), onClick: () => setDialog({ intent: 'caption' }), children: caption ? 'Edit caption…' : 'Add caption…' })), options.allowCaption && caption && (_jsx(Menu.Item, { leftSection: _jsx(IconX, { size: 16, stroke: 1.75 }), onClick: () => updateNode((node) => node.setCaption(null)), children: "Remove caption" })), _jsx(Menu.Divider, {}), _jsx(Menu.Item, { color: "red", leftSection: _jsx(IconTrash, { size: 16, stroke: 1.75 }), onClick: removeImage, children: "Delete image" })] })] }), _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: () => {
161
165
  setDialog(null);
162
166
  editor.focus();
163
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":"AAOA,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,+BAyJ3F"}
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"}
@@ -5,6 +5,8 @@ import { normalizeImageUrl } from './imageUrl.js';
5
5
  import { dataUrlBytes, dataUrlToFile, fitDimension, mimeFromSrc } from './imageEdit.js';
6
6
  import { ImageCropper } from './ImageCropper.js';
7
7
  import { useImageOptions } from './ImageOptionsContext.js';
8
+ import { useUiStrings } from '../i18n/useStrings.js';
9
+ import { usePortalTarget } from '../PortalTargetContext.js';
8
10
  /** Natural pixel size of an image URL (for the aspect ratio and size presets). */
9
11
  function useNaturalSize(src) {
10
12
  const [size, setSize] = useState(null);
@@ -29,6 +31,8 @@ function useNaturalSize(src) {
29
31
  * its context menu. Edits that change pixels are re-embedded as a data URI,
30
32
  * or stored through the host's `onImageUpload` when there is one. */
31
33
  export function ImageCropDialog({ opened, initial, onSubmit, onClose }) {
34
+ const strings = useUiStrings();
35
+ const portalTarget = usePortalTarget();
32
36
  const { maxSizeMB, onImageUpload } = useImageOptions();
33
37
  const [pixelSrc, setPixelSrc] = useState(null);
34
38
  const [width, setWidth] = useState(null);
@@ -75,7 +79,7 @@ export function ImageCropDialog({ opened, initial, onSubmit, onClose }) {
75
79
  let src = effectiveSrc;
76
80
  if (pixelSrc) {
77
81
  if (dataUrlBytes(pixelSrc) > maxSizeMB * 1024 * 1024) {
78
- setError(`Image is larger than ${maxSizeMB} MB`);
82
+ setError(strings.imageCropDialog.errors.largerThan(maxSizeMB));
79
83
  return;
80
84
  }
81
85
  if (onImageUpload) {
@@ -83,7 +87,7 @@ export function ImageCropDialog({ opened, initial, onSubmit, onClose }) {
83
87
  const ext = mimeFromSrc(pixelSrc).split('/')[1];
84
88
  src = await onImageUpload(dataUrlToFile(pixelSrc, `image.${ext}`));
85
89
  if (!normalizeImageUrl(src)) {
86
- setError('The upload handler returned an unusable image URL');
90
+ setError(strings.imageCropDialog.errors.uploadUnusable);
87
91
  return;
88
92
  }
89
93
  }
@@ -91,23 +95,26 @@ export function ImageCropDialog({ opened, initial, onSubmit, onClose }) {
91
95
  onSubmit({ src, width, height });
92
96
  }
93
97
  catch (err) {
94
- setError(err instanceof Error ? err.message : 'Could not save the image');
98
+ // ImageCropper's own pixel-edit error message (imageEdit.ts,
99
+ // applyPixelOp) is shown as-is when present — see the note in
100
+ // ImageDialog.tsx for why that pure utility's messages stay English.
101
+ setError(err instanceof Error ? err.message : strings.imageCropDialog.errors.couldNotSave);
95
102
  }
96
103
  finally {
97
104
  setBusy(false);
98
105
  }
99
106
  };
100
- return (_jsx(Modal, { opened: opened, onClose: onClose, title: "Crop & resize", 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) => {
101
108
  setPixelSrc(edited);
102
109
  resetSize();
103
110
  }, onReset: () => {
104
111
  setPixelSrc(null);
105
112
  setWidth(initial.width);
106
113
  setHeight(initial.height);
107
- } }), _jsx(Divider, { label: "Size", labelPosition: "left" }), _jsxs(Stack, { gap: "sm", children: [_jsxs(Group, { grow: true, align: "flex-end", children: [_jsx(NumberInput, { label: "Width (px)", min: 1, max: 10000, hideControls: true, placeholder: natural ? String(natural.width) : 'Auto', value: width ?? '', onChange: (v) => onSizeChange('width', v) }), _jsx(NumberInput, { label: "Height (px)", min: 1, max: 10000, hideControls: true, placeholder: natural ? String(natural.height) : 'Auto', value: height ?? '', onChange: (v) => onSizeChange('height', v) })] }), _jsx(Checkbox, { label: "Keep proportions", checked: lockAspect, onChange: (e) => setLockAspect(e.currentTarget.checked) }), _jsxs(Group, { gap: "xs", children: [[25, 50, 75, 100].map((pct) => (_jsxs(Button, { size: "xs", variant: "default", disabled: !natural, onClick: () => {
114
+ } }), _jsx(Divider, { label: strings.imageCropDialog.sizeLabel, labelPosition: "left" }), _jsxs(Stack, { gap: "sm", children: [_jsxs(Group, { grow: true, align: "flex-end", children: [_jsx(NumberInput, { label: strings.imageCropDialog.widthLabel, min: 1, max: 10000, hideControls: true, placeholder: natural ? String(natural.width) : strings.imageCropDialog.autoPlaceholder, value: width ?? '', onChange: (v) => onSizeChange('width', v) }), _jsx(NumberInput, { label: strings.imageCropDialog.heightLabel, min: 1, max: 10000, hideControls: true, placeholder: natural ? String(natural.height) : strings.imageCropDialog.autoPlaceholder, value: height ?? '', onChange: (v) => onSizeChange('height', v) })] }), _jsx(Checkbox, { label: strings.imageCropDialog.keepProportions, checked: lockAspect, onChange: (e) => setLockAspect(e.currentTarget.checked) }), _jsxs(Group, { gap: "xs", children: [[25, 50, 75, 100].map((pct) => (_jsxs(Button, { size: "xs", variant: "default", disabled: !natural, onClick: () => {
108
115
  if (!natural)
109
116
  return;
110
117
  setWidth(Math.round((natural.width * pct) / 100));
111
118
  setHeight(Math.round((natural.height * pct) / 100));
112
- }, children: [pct, "%"] }, pct))), _jsx(Button, { size: "xs", variant: "subtle", onClick: resetSize, children: "Original size" })] }), _jsxs(Text, { size: "xs", c: "dimmed", children: ["You can also drag the corner handle on a selected image. ", natural ? `Original: ${natural.width} × ${natural.height} px.` : ''] })] }), error && (_jsx(Alert, { color: "red", variant: "light", p: "xs", children: error })), _jsxs(Group, { justify: "flex-end", gap: "xs", children: [_jsx(Button, { variant: "default", onClick: onClose, children: "Cancel" }), _jsx(Button, { type: "submit", loading: busy, children: "Save" })] })] }) }) }));
119
+ }, children: [pct, "%"] }, pct))), _jsx(Button, { size: "xs", variant: "subtle", onClick: resetSize, children: strings.imageCropDialog.originalSize })] }), _jsx(Text, { size: "xs", c: "dimmed", children: strings.imageCropDialog.dragHint(natural) })] }), error && (_jsx(Alert, { color: "red", variant: "light", p: "xs", children: error })), _jsxs(Group, { justify: "flex-end", gap: "xs", children: [_jsx(Button, { variant: "default", onClick: onClose, children: strings.common.cancel }), _jsx(Button, { type: "submit", loading: busy, children: strings.common.save })] })] }) }) }));
113
120
  }
@@ -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":"AAMA,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,+BAmPjG"}
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"}
@@ -5,11 +5,13 @@ import { IconPhotoDown } from '@tabler/icons-react';
5
5
  import { ACCEPTED_IMAGE_TYPES, normalizeImageUrl, validateImageFile } from './imageUrl.js';
6
6
  import { dataUrlBytes, dataUrlToFile, fetchImageAsDataUrl, mimeFromSrc } from './imageEdit.js';
7
7
  import { useImageOptions } from './ImageOptionsContext.js';
8
+ import { useUiStrings } from '../i18n/useStrings.js';
9
+ import { usePortalTarget } from '../PortalTargetContext.js';
8
10
  function readAsDataUrl(file) {
9
11
  return new Promise((resolve, reject) => {
10
12
  const reader = new FileReader();
11
13
  reader.onload = () => resolve(String(reader.result));
12
- reader.onerror = () => reject(reader.error ?? new Error('Could not read the file'));
14
+ reader.onerror = () => reject(new Error('READ_FAILED'));
13
15
  reader.readAsDataURL(file);
14
16
  });
15
17
  }
@@ -21,6 +23,8 @@ function readAsDataUrl(file) {
21
23
  * image's context menu once it has pixels of its own to edit.
22
24
  */
23
25
  export function ImageDialog({ mode, opened, initial, intent, onSubmit, onClose }) {
26
+ const strings = useUiStrings();
27
+ const portalTarget = usePortalTarget();
24
28
  const { allowLinked, allowEmbedded, allowCaption, maxSizeMB, onImageUpload, fetchImage } = useImageOptions();
25
29
  const editing = mode === 'edit' && initial !== undefined;
26
30
  const defaultSource = editing ? 'keep' : allowLinked ? 'linked' : 'embedded';
@@ -74,8 +78,8 @@ export function ImageDialog({ mode, opened, initial, intent, onSubmit, onClose }
74
78
  try {
75
79
  setFileSrc(await readAsDataUrl(next));
76
80
  }
77
- catch (err) {
78
- setError(err instanceof Error ? err.message : 'Could not read the file');
81
+ catch {
82
+ setError(strings.imageDialog.errors.couldNotReadFile);
79
83
  }
80
84
  };
81
85
  /** Downloads a linked image and switches the dialog to its embedded copy. */
@@ -87,13 +91,17 @@ export function ImageDialog({ mode, opened, initial, intent, onSubmit, onClose }
87
91
  try {
88
92
  const dataUrl = await fetchImageAsDataUrl(src, fetchImage);
89
93
  if (dataUrlBytes(dataUrl) > maxSizeMB * 1024 * 1024)
90
- throw new Error(`Image is larger than ${maxSizeMB} MB`);
94
+ throw new Error(strings.imageDialog.errors.largerThan(maxSizeMB));
91
95
  setSource('embedded');
92
96
  setFile(null);
93
97
  setFileSrc(dataUrl);
94
98
  }
95
99
  catch (err) {
96
- setError(err instanceof Error ? err.message : 'Could not convert the image');
100
+ // fetchImageAsDataUrl's own errors (network/CORS failures, unsupported
101
+ // type) are English (packages/react/src/image/imageEdit.ts) — shown
102
+ // as-is since threading locale into that pure utility isn't worth the
103
+ // refactor here; only the size-limit message above is ours to localize.
104
+ setError(err instanceof Error ? err.message : strings.imageDialog.errors.couldNotConvert);
97
105
  }
98
106
  finally {
99
107
  setConverting(false);
@@ -103,7 +111,11 @@ export function ImageDialog({ mode, opened, initial, intent, onSubmit, onClose }
103
111
  e.preventDefault();
104
112
  setError(null);
105
113
  if (!baseSrc) {
106
- setError(source === 'linked' ? 'Enter an http(s) or relative image URL' : source === 'embedded' ? 'Choose an image file' : 'No image');
114
+ setError(source === 'linked'
115
+ ? strings.imageDialog.errors.enterUrl
116
+ : source === 'embedded'
117
+ ? strings.imageDialog.errors.chooseFile
118
+ : strings.imageDialog.errors.noImage);
107
119
  return;
108
120
  }
109
121
  try {
@@ -119,7 +131,7 @@ export function ImageDialog({ mode, opened, initial, intent, onSubmit, onClose }
119
131
  }
120
132
  if (newPixels) {
121
133
  if (dataUrlBytes(newPixels) > maxSizeMB * 1024 * 1024) {
122
- setError(`Image is larger than ${maxSizeMB} MB`);
134
+ setError(strings.imageDialog.errors.largerThan(maxSizeMB));
123
135
  return;
124
136
  }
125
137
  linkType = 'embedded';
@@ -129,7 +141,7 @@ export function ImageDialog({ mode, opened, initial, intent, onSubmit, onClose }
129
141
  const upload = !file ? dataUrlToFile(newPixels, `image.${ext}`) : file;
130
142
  src = await onImageUpload(upload);
131
143
  if (!normalizeImageUrl(src)) {
132
- setError('The upload handler returned an unusable image URL');
144
+ setError(strings.imageDialog.errors.uploadUnusable);
133
145
  return;
134
146
  }
135
147
  }
@@ -146,17 +158,19 @@ export function ImageDialog({ mode, opened, initial, intent, onSubmit, onClose }
146
158
  });
147
159
  }
148
160
  catch (err) {
149
- setError(err instanceof Error ? err.message : 'Could not save the image');
161
+ // fetchImageAsDataUrl's own network/CORS error message (imageEdit.ts)
162
+ // is shown as-is when present — see note in `convert` above.
163
+ setError(err instanceof Error ? err.message : strings.imageDialog.errors.couldNotSave);
150
164
  }
151
165
  finally {
152
166
  setBusy(false);
153
167
  }
154
168
  };
155
- const convertButton = (_jsx(Button, { size: "xs", variant: "light", leftSection: _jsx(IconPhotoDown, { size: 14 }), loading: converting, disabled: !baseSrc, onClick: () => convert(baseSrc), children: "Convert to embedded image" }));
156
- return (_jsx(Modal, { opened: opened, onClose: onClose, title: editing ? 'Edit image' : 'Insert image', 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: [
157
- ...(editing ? [{ value: 'keep', label: 'Current image' }] : []),
158
- ...(allowLinked ? [{ value: 'linked', label: 'From URL' }] : []),
159
- ...(allowEmbedded ? [{ value: 'embedded', label: 'Upload' }] : []),
160
- ] })), source === 'keep' && (_jsxs(Stack, { gap: 6, children: [_jsx(Text, { size: "sm", c: "dimmed", lineClamp: 2, style: { wordBreak: 'break-all' }, children: initial?.linkType === 'embedded' ? 'Embedded in the document' : `Linked from ${initial?.src}` }), initial?.linkType === 'linked' && allowEmbedded && (_jsxs(Group, { gap: "xs", children: [convertButton, _jsx(Text, { size: "xs", c: "dimmed", children: "Downloads a copy into the document, so it can be cropped, rotated and resized." })] }))] })), source === 'linked' && (_jsxs(_Fragment, { children: [_jsx(TextInput, { label: "Image URL", placeholder: "https://example.com/photo.jpg", value: url, onChange: (e) => setUrl(e.currentTarget.value), "data-autofocus": intent !== 'caption' ? true : undefined }), allowEmbedded && (_jsx(Checkbox, { label: "Embed a copy in the document", description: "Downloads the image so it can be edited and no longer depends on the URL.", checked: embedCopy, onChange: (e) => setEmbedCopy(e.currentTarget.checked) }))] })), source === 'embedded' &&
161
- (convertedFromUrl ? (_jsx(Text, { size: "sm", c: "dimmed", children: "Converted from the URL: a copy will be embedded in the document." })) : (_jsx(FileInput, { label: "Image file", placeholder: "Choose an image", accept: ACCEPTED_IMAGE_TYPES.join(','), value: file, onChange: changeFile, description: `Up to ${maxSizeMB} MB${onImageUpload ? '' : '; stored inside the document'}` }))), _jsx(TextInput, { label: "Alt text", description: "Describes the image for screen readers", value: altText, onChange: (e) => setAltText(e.currentTarget.value) }), allowCaption && (_jsx(TextInput, { label: "Caption", description: "Shown under the image. Leave empty for none.", value: caption, onChange: (e) => setCaption(e.currentTarget.value), "data-autofocus": intent === 'caption' ? true : undefined })), error && (_jsx(Alert, { color: "red", variant: "light", p: "xs", children: error })), _jsxs(Group, { justify: "flex-end", gap: "xs", children: [_jsx(Button, { variant: "default", onClick: onClose, children: "Cancel" }), _jsx(Button, { type: "submit", loading: busy, children: editing ? 'Save' : 'Insert' })] })] }) }) }));
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 }));
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: [
171
+ ...(editing ? [{ value: 'keep', label: strings.imageDialog.currentImage }] : []),
172
+ ...(allowLinked ? [{ value: 'linked', label: strings.imageDialog.fromUrl }] : []),
173
+ ...(allowEmbedded ? [{ value: 'embedded', label: strings.imageDialog.upload }] : []),
174
+ ] })), source === 'keep' && (_jsxs(Stack, { gap: 6, children: [_jsx(Text, { size: "sm", c: "dimmed", lineClamp: 2, style: { wordBreak: 'break-all' }, children: initial?.linkType === 'embedded' ? strings.imageDialog.embeddedInDocument : strings.imageDialog.linkedFrom(initial?.src ?? '') }), initial?.linkType === 'linked' && allowEmbedded && (_jsxs(Group, { gap: "xs", children: [convertButton, _jsx(Text, { size: "xs", c: "dimmed", children: strings.imageDialog.convertHint })] }))] })), source === 'linked' && (_jsxs(_Fragment, { children: [_jsx(TextInput, { label: strings.imageDialog.imageUrlLabel, placeholder: strings.imageDialog.imageUrlPlaceholder, value: url, onChange: (e) => setUrl(e.currentTarget.value), "data-autofocus": intent !== 'caption' ? true : undefined }), allowEmbedded && (_jsx(Checkbox, { label: strings.imageDialog.embedCopyLabel, description: strings.imageDialog.embedCopyDescription, checked: embedCopy, onChange: (e) => setEmbedCopy(e.currentTarget.checked) }))] })), source === 'embedded' &&
175
+ (convertedFromUrl ? (_jsx(Text, { size: "sm", c: "dimmed", children: strings.imageDialog.convertedFromUrl })) : (_jsx(FileInput, { label: strings.imageDialog.fileLabel, placeholder: strings.imageDialog.filePlaceholder, accept: ACCEPTED_IMAGE_TYPES.join(','), value: file, onChange: changeFile, description: strings.imageDialog.fileDescription(maxSizeMB, !onImageUpload) }))), _jsx(TextInput, { label: strings.imageDialog.altTextLabel, description: strings.imageDialog.altTextDescription, value: altText, onChange: (e) => setAltText(e.currentTarget.value) }), allowCaption && (_jsx(TextInput, { label: strings.imageDialog.captionLabel, description: strings.imageDialog.captionDescription, value: caption, onChange: (e) => setCaption(e.currentTarget.value), "data-autofocus": intent === 'caption' ? true : undefined })), error && (_jsx(Alert, { color: "red", variant: "light", p: "xs", children: error })), _jsxs(Group, { justify: "flex-end", gap: "xs", children: [_jsx(Button, { variant: "default", onClick: onClose, children: strings.common.cancel }), _jsx(Button, { type: "submit", loading: busy, children: editing ? strings.common.save : strings.common.insert })] })] }) }) }));
162
176
  }
package/dist/index.d.ts CHANGED
@@ -5,4 +5,6 @@ export { ImageNode, $createImageNode, $isImageNode } from './image/ImageNode.js'
5
5
  export type { ImagePayload, ImageLinkType, SerializedImageNode } from './image/ImageNode.js';
6
6
  export { DEFAULT_FONT_OPTIONS, URDU_WEB_FONT_OPTIONS, FONT_SIZES_PX } from './fonts/index.js';
7
7
  export type { FontOption } from './fonts/index.js';
8
+ export { STRINGS, getStrings, useStrings, useUiStrings, UiStringsContext } from './i18n/index.js';
9
+ export type { Locale, Strings } from './i18n/index.js';
8
10
  //# sourceMappingURL=index.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,UAAU,EAAE,MAAM,cAAc,CAAC;AAC1C,YAAY,EAAE,eAAe,EAAE,SAAS,EAAE,oBAAoB,EAAE,MAAM,cAAc,CAAC;AACrF,OAAO,EAAE,mBAAmB,EAAE,MAAM,sBAAsB,CAAC;AAC3D,OAAO,EAAE,SAAS,EAAE,gBAAgB,EAAE,YAAY,EAAE,MAAM,mBAAmB,CAAC;AAC9E,YAAY,EAAE,YAAY,EAAE,aAAa,EAAE,mBAAmB,EAAE,MAAM,mBAAmB,CAAC;AAC1F,OAAO,EAAE,oBAAoB,EAAE,qBAAqB,EAAE,aAAa,EAAE,MAAM,SAAS,CAAC;AACrF,YAAY,EAAE,UAAU,EAAE,MAAM,SAAS,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,UAAU,EAAE,MAAM,cAAc,CAAC;AAC1C,YAAY,EAAE,eAAe,EAAE,SAAS,EAAE,oBAAoB,EAAE,MAAM,cAAc,CAAC;AACrF,OAAO,EAAE,mBAAmB,EAAE,MAAM,sBAAsB,CAAC;AAC3D,OAAO,EAAE,SAAS,EAAE,gBAAgB,EAAE,YAAY,EAAE,MAAM,mBAAmB,CAAC;AAC9E,YAAY,EAAE,YAAY,EAAE,aAAa,EAAE,mBAAmB,EAAE,MAAM,mBAAmB,CAAC;AAC1F,OAAO,EAAE,oBAAoB,EAAE,qBAAqB,EAAE,aAAa,EAAE,MAAM,SAAS,CAAC;AACrF,YAAY,EAAE,UAAU,EAAE,MAAM,SAAS,CAAC;AAC1C,OAAO,EAAE,OAAO,EAAE,UAAU,EAAE,UAAU,EAAE,YAAY,EAAE,gBAAgB,EAAE,MAAM,QAAQ,CAAC;AACzF,YAAY,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,QAAQ,CAAC"}
package/dist/index.js CHANGED
@@ -2,3 +2,4 @@ export { EditorRoot } from './EditorRoot.js';
2
2
  export { defaultMantineTheme } from './theme/mantineTheme.js';
3
3
  export { ImageNode, $createImageNode, $isImageNode } from './image/ImageNode.js';
4
4
  export { DEFAULT_FONT_OPTIONS, URDU_WEB_FONT_OPTIONS, FONT_SIZES_PX } from './fonts/index.js';
5
+ export { STRINGS, getStrings, useStrings, useUiStrings, UiStringsContext } from './i18n/index.js';
@@ -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.60",
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": {