@inshapardaz/likhari-react 0.1.5 → 0.1.6-dev.43
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/EditorRoot.d.ts +15 -0
- package/dist/EditorRoot.d.ts.map +1 -1
- package/dist/EditorRoot.js +18 -3
- package/dist/components/Toolbar.d.ts +6 -1
- package/dist/components/Toolbar.d.ts.map +1 -1
- package/dist/components/Toolbar.js +83 -8
- package/dist/fonts/index.d.ts +22 -0
- package/dist/fonts/index.d.ts.map +1 -0
- package/dist/fonts/index.js +20 -0
- package/dist/fonts/urduWebFonts.d.ts +14 -0
- package/dist/fonts/urduWebFonts.d.ts.map +1 -0
- package/dist/fonts/urduWebFonts.js +66 -0
- package/dist/image/ImageComponent.d.ts +22 -0
- package/dist/image/ImageComponent.d.ts.map +1 -0
- package/dist/image/ImageComponent.js +132 -0
- package/dist/image/ImageCropper.d.ts +14 -0
- package/dist/image/ImageCropper.d.ts.map +1 -0
- package/dist/image/ImageCropper.js +87 -0
- package/dist/image/ImageDialog.d.ts +30 -0
- package/dist/image/ImageDialog.d.ts.map +1 -0
- package/dist/image/ImageDialog.js +194 -0
- package/dist/image/ImageNode.d.ts +64 -0
- package/dist/image/ImageNode.d.ts.map +1 -0
- package/dist/image/ImageNode.js +133 -0
- package/dist/image/ImageOptionsContext.d.ts +17 -0
- package/dist/image/ImageOptionsContext.d.ts.map +1 -0
- package/dist/image/ImageOptionsContext.js +10 -0
- package/dist/image/imageEdit.d.ts +43 -0
- package/dist/image/imageEdit.d.ts.map +1 -0
- package/dist/image/imageEdit.js +103 -0
- package/dist/image/imageUrl.d.ts +14 -0
- package/dist/image/imageUrl.d.ts.map +1 -0
- package/dist/image/imageUrl.js +27 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -0
- package/dist/nodes.d.ts.map +1 -1
- package/dist/nodes.js +2 -0
- package/dist/theme/editor.css +80 -0
- package/dist/theme/editorTheme.d.ts.map +1 -1
- package/dist/theme/editorTheme.js +1 -0
- package/dist/utils/style.d.ts +4 -0
- package/dist/utils/style.d.ts.map +1 -0
- package/dist/utils/style.js +15 -0
- package/package.json +1 -1
package/dist/EditorRoot.d.ts
CHANGED
|
@@ -2,6 +2,7 @@ import type { MantineThemeOverride } from '@mantine/core';
|
|
|
2
2
|
import type { SerializedEditorState } from 'lexical';
|
|
3
3
|
import { type FormatId } from '@inshapardaz/likhari-converters';
|
|
4
4
|
import { type EditorFeatureConfig, type FeatureConfigPresetName } from '@inshapardaz/likhari-core';
|
|
5
|
+
import { type FontOption } from './fonts/index.js';
|
|
5
6
|
export interface EditorInitialContent {
|
|
6
7
|
format: Extract<FormatId, 'lexical-json' | 'plain-text'>;
|
|
7
8
|
value: string;
|
|
@@ -37,6 +38,20 @@ export interface EditorRootProps {
|
|
|
37
38
|
* the unsaved-changes state) or to hide it while keeping `onSave`.
|
|
38
39
|
*/
|
|
39
40
|
showSave?: boolean;
|
|
41
|
+
/**
|
|
42
|
+
* Stores an uploaded image and returns the URL to reference it by. Without
|
|
43
|
+
* it, uploaded images are embedded in the document as base64 data URIs —
|
|
44
|
+
* simple, but they bloat the document (see `images.maxSizeMB`).
|
|
45
|
+
*/
|
|
46
|
+
onImageUpload?: (file: File) => Promise<string>;
|
|
47
|
+
/**
|
|
48
|
+
* Entries for the toolbar's font-family dropdown. Defaults to
|
|
49
|
+
* `DEFAULT_FONT_OPTIONS` (generic Latin faces plus the Urdu/Arabic-script
|
|
50
|
+
* collection from inshapardaz/urdu-web-fonts, the same source qari uses).
|
|
51
|
+
* Spread the defaults to add your own, e.g.
|
|
52
|
+
* `[...DEFAULT_FONT_OPTIONS, { name: 'Mine', family: '"Mine", serif' }]`.
|
|
53
|
+
*/
|
|
54
|
+
fontOptions?: FontOption[];
|
|
40
55
|
}
|
|
41
56
|
export interface EditorRef {
|
|
42
57
|
getContent(format: FormatId): string;
|
package/dist/EditorRoot.d.ts.map
CHANGED
|
@@ -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;AAS1D,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;
|
|
1
|
+
{"version":3,"file":"EditorRoot.d.ts","sourceRoot":"","sources":["../src/EditorRoot.tsx"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,oBAAoB,EAAE,MAAM,eAAe,CAAC;AAS1D,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,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,IAAI,GAAG,IAAI,GAAG,cAAc,CAAC;IACtC,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;;;;;;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,uGA2IrB,CAAC"}
|
package/dist/EditorRoot.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
-
import { forwardRef, useImperativeHandle, useMemo, useRef, useState } from 'react';
|
|
2
|
+
import { forwardRef, useEffect, 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';
|
|
@@ -14,15 +14,30 @@ import { EDITOR_NODES } from './nodes.js';
|
|
|
14
14
|
import { editorTheme } from './theme/editorTheme.js';
|
|
15
15
|
import { EditorThemeProvider } from './theme/EditorThemeProvider.js';
|
|
16
16
|
import { Toolbar } from './components/Toolbar.js';
|
|
17
|
+
import { injectUrduWebFontsCss } from './fonts/index.js';
|
|
17
18
|
import { LinkPastePlugin } from './plugins/LinkPastePlugin.js';
|
|
19
|
+
import { ImageOptionsContext } from './image/ImageOptionsContext.js';
|
|
18
20
|
function initialEditorStateJson(initialContent) {
|
|
19
21
|
if (!initialContent)
|
|
20
22
|
return undefined;
|
|
21
23
|
const state = defaultFormatRegistry.parse(initialContent.format, initialContent.value);
|
|
22
24
|
return JSON.stringify(state);
|
|
23
25
|
}
|
|
24
|
-
export const EditorRoot = forwardRef(function EditorRoot({ documentId, initialContent, featureConfig, featurePreset, theme, colorScheme, locale = 'en', placeholder = 'Start writing…', height = '480px', onChange, onSave, showSave = Boolean(onSave), }, ref) {
|
|
26
|
+
export const EditorRoot = forwardRef(function EditorRoot({ documentId, initialContent, featureConfig, featurePreset, theme, colorScheme, locale = 'en', placeholder = 'Start writing…', height = '480px', onChange, onSave, showSave = Boolean(onSave), onImageUpload, fontOptions, }, ref) {
|
|
25
27
|
const config = useMemo(() => resolveFeatureConfig(featureConfig, featurePreset), [featureConfig, featurePreset]);
|
|
28
|
+
// The urdu-web-fonts stylesheets are needed for the font dropdown and for
|
|
29
|
+
// RTL content, whose canvas font (editor.css) is one of those families.
|
|
30
|
+
useEffect(() => {
|
|
31
|
+
if (config.font.family || locale !== 'en')
|
|
32
|
+
injectUrduWebFontsCss();
|
|
33
|
+
}, [config.font.family, locale]);
|
|
34
|
+
const imageOptions = useMemo(() => ({
|
|
35
|
+
allowLinked: config.images.linked,
|
|
36
|
+
allowEmbedded: config.images.embedded,
|
|
37
|
+
allowCaption: config.images.caption,
|
|
38
|
+
maxSizeMB: config.images.maxSizeMB,
|
|
39
|
+
onImageUpload,
|
|
40
|
+
}), [config.images.linked, config.images.embedded, config.images.caption, config.images.maxSizeMB, onImageUpload]);
|
|
26
41
|
const editorStateRef = useRef(null);
|
|
27
42
|
const lastSavedJsonRef = useRef(null);
|
|
28
43
|
const [isDirty, setIsDirty] = useState(false);
|
|
@@ -85,5 +100,5 @@ export const EditorRoot = forwardRef(function EditorRoot({ documentId, initialCo
|
|
|
85
100
|
onSave?.(content, format);
|
|
86
101
|
};
|
|
87
102
|
const dir = locale === 'en' ? 'ltr' : 'rtl';
|
|
88
|
-
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: _jsxs(LexicalComposer, { initialConfig: initialConfig, children: [_jsx(Toolbar, { config: config, onSave: handleSave, isDirty: isDirty, showSave: showSave }), _jsx("div", { className: "likhari-canvas", children: _jsx(RichTextPlugin, { contentEditable: _jsx(ContentEditable, { className: "likhari-content-editable", dir: dir, "aria-label": "Editor content" }), placeholder: _jsx("div", { className: "likhari-placeholder", children: placeholder }), ErrorBoundary: LexicalErrorBoundary }) }), config.history && _jsx(HistoryPlugin, {}), (config.lists.bullet || config.lists.numbered || config.lists.check) && _jsx(ListPlugin, {}), config.links && _jsx(LinkPlugin, {}), config.links && _jsx(LinkPastePlugin, {}), _jsx(OnChangePlugin, { onChange: handleChange })] }) }) }));
|
|
103
|
+
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(ImageOptionsContext.Provider, { value: imageOptions, children: _jsxs(LexicalComposer, { initialConfig: initialConfig, children: [_jsx(Toolbar, { config: config, onSave: handleSave, isDirty: isDirty, showSave: showSave, fontOptions: fontOptions, direction: dir }), _jsx("div", { className: "likhari-canvas", children: _jsx(RichTextPlugin, { contentEditable: _jsx(ContentEditable, { className: "likhari-content-editable", dir: dir, "aria-label": "Editor content" }), placeholder: _jsx("div", { className: "likhari-placeholder", children: placeholder }), ErrorBoundary: LexicalErrorBoundary }) }), config.history && _jsx(HistoryPlugin, {}), (config.lists.bullet || config.lists.numbered || config.lists.check) && _jsx(ListPlugin, {}), config.links && _jsx(LinkPlugin, {}), config.links && _jsx(LinkPastePlugin, {}), _jsx(OnChangePlugin, { onChange: handleChange })] }) }) }) }));
|
|
89
104
|
});
|
|
@@ -1,9 +1,14 @@
|
|
|
1
1
|
import type { ResolvedEditorFeatureConfig } from '@inshapardaz/likhari-core';
|
|
2
|
+
import { type FontOption } from '../fonts/index.js';
|
|
2
3
|
export interface ToolbarProps {
|
|
3
4
|
config: ResolvedEditorFeatureConfig;
|
|
4
5
|
onSave?: () => void;
|
|
5
6
|
isDirty?: boolean;
|
|
6
7
|
showSave?: boolean;
|
|
8
|
+
/** Font-family dropdown entries; defaults to DEFAULT_FONT_OPTIONS. */
|
|
9
|
+
fontOptions?: FontOption[];
|
|
10
|
+
/** Text direction of the canvas; picks which default font and size are pre-selected. */
|
|
11
|
+
direction?: 'ltr' | 'rtl';
|
|
7
12
|
}
|
|
8
|
-
export declare function Toolbar({ config, onSave, isDirty, showSave }: ToolbarProps): import("react").JSX.Element;
|
|
13
|
+
export declare function Toolbar({ config, onSave, isDirty, showSave, fontOptions, direction }: ToolbarProps): import("react").JSX.Element;
|
|
9
14
|
//# sourceMappingURL=Toolbar.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"Toolbar.d.ts","sourceRoot":"","sources":["../../src/components/Toolbar.tsx"],"names":[],"mappings":"
|
|
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;AAmPtG,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;CAC3B;AAED,wBAAgB,OAAO,CAAC,EAAE,MAAM,EAAE,MAAM,EAAE,OAAO,EAAE,QAAQ,EAAE,WAAkC,EAAE,SAAiB,EAAE,EAAE,YAAY,+BAgoBjI"}
|
|
@@ -2,21 +2,31 @@ import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-run
|
|
|
2
2
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
|
3
3
|
import { Menu, Select } from '@mantine/core';
|
|
4
4
|
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext';
|
|
5
|
-
import { $getSelection, $isRangeSelection, $isElementNode, $setSelection, $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';
|
|
6
|
-
import { $setBlocksType } from '@lexical/selection';
|
|
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';
|
|
6
|
+
import { $getSelectionStyleValueForProperty, $patchStyleText, $setBlocksType } from '@lexical/selection';
|
|
7
7
|
import { $createParagraphNode } from 'lexical';
|
|
8
8
|
import { $createHeadingNode, $createQuoteNode, $isHeadingNode, $isQuoteNode } from '@lexical/rich-text';
|
|
9
9
|
import { $isListNode, INSERT_CHECK_LIST_COMMAND, INSERT_ORDERED_LIST_COMMAND, INSERT_UNORDERED_LIST_COMMAND, REMOVE_LIST_COMMAND, } from '@lexical/list';
|
|
10
|
-
import { $findMatchingParent } from '@lexical/utils';
|
|
10
|
+
import { $findMatchingParent, $insertNodeToNearestRoot } from '@lexical/utils';
|
|
11
|
+
import { ImageDialog } from '../image/ImageDialog.js';
|
|
12
|
+
import { $createImageNode } from '../image/ImageNode.js';
|
|
11
13
|
import { $createLinkNode, $isLinkNode, TOGGLE_LINK_COMMAND } from '@lexical/link';
|
|
12
14
|
import { LinkDialog } from './LinkDialog.js';
|
|
13
15
|
import { normalizeLinkUrl } from '../utils/linkUrl.js';
|
|
16
|
+
import { CANVAS_FONT_DEFAULTS, DEFAULT_FONT_OPTIONS, FONT_SIZES_PX } from '../fonts/index.js';
|
|
17
|
+
import { setStyleProperty } from '../utils/style.js';
|
|
14
18
|
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';
|
|
19
|
+
/** Sentinel for "no inline style": Lexical returns the default it is given when
|
|
20
|
+
* the selection has none, so this tells "unstyled" (show the canvas default)
|
|
21
|
+
* apart from "styled with a value the dropdown doesn't list" (show nothing). */
|
|
22
|
+
const UNSET_STYLE = '__unset__';
|
|
15
23
|
const INITIAL_STATE = {
|
|
16
24
|
blockType: 'paragraph',
|
|
17
25
|
activeFormats: new Set(),
|
|
18
26
|
elementFormat: 'start',
|
|
19
27
|
listType: null,
|
|
28
|
+
fontFamily: UNSET_STYLE,
|
|
29
|
+
fontSize: UNSET_STYLE,
|
|
20
30
|
isLink: false,
|
|
21
31
|
linkUrl: '',
|
|
22
32
|
canUndo: false,
|
|
@@ -46,8 +56,8 @@ function StubButton({ icon, title }) {
|
|
|
46
56
|
* labels the control itself, since a dropdown can't show one per option in
|
|
47
57
|
* its closed state. The dropdown portals to <body>, so it is not clipped by
|
|
48
58
|
* the editor's `overflow: hidden` frame. */
|
|
49
|
-
function ToolbarSelect({ icon: Icon, label, value, data, width, disabled, placeholder, onChange }) {
|
|
50
|
-
return (_jsx(Select, { size: "xs", w: width, "aria-label": label, title: disabled ? `${label} (coming soon)` : label, data: data, value: value, placeholder: placeholder, disabled: disabled, 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) }));
|
|
59
|
+
function ToolbarSelect({ icon: Icon, label, value, data, width, disabled, placeholder, searchable, renderOption, onChange }) {
|
|
60
|
+
return (_jsx(Select, { size: "xs", w: width, "aria-label": label, title: disabled ? `${label} (coming soon)` : label, data: data, value: value, placeholder: placeholder, disabled: disabled, searchable: searchable, renderOption: renderOption, nothingFoundMessage: searchable ? 'No match' : 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) }));
|
|
51
61
|
}
|
|
52
62
|
function OverflowItem({ icon: Icon, label, active, onClick, }) {
|
|
53
63
|
return (_jsx(Menu.Item, { leftSection: _jsx(Icon, { size: ICON_SIZE, stroke: ICON_STROKE }), "data-active": active ? 'true' : 'false', className: "likhari-overflow-item",
|
|
@@ -63,7 +73,7 @@ function LinkMenuItems({ url, onEdit, onRemove }) {
|
|
|
63
73
|
// Not a URL the editor would accept (e.g. loaded from a document): show it, don't make it clickable.
|
|
64
74
|
_jsx(Menu.Item, { disabled: true, title: url, className: "likhari-link-menu-url", children: url || '(no URL)' })), _jsx(Menu.Item, { leftSection: _jsx(IconPencil, { size: ICON_SIZE, stroke: ICON_STROKE }), onMouseDown: (e) => e.preventDefault(), onClick: onEdit, children: "Edit link" }), _jsx(Menu.Item, { color: "red", leftSection: _jsx(IconLinkOff, { size: ICON_SIZE, stroke: ICON_STROKE }), onMouseDown: (e) => e.preventDefault(), onClick: onRemove, children: "Remove link" })] }));
|
|
65
75
|
}
|
|
66
|
-
export function Toolbar({ config, onSave, isDirty, showSave }) {
|
|
76
|
+
export function Toolbar({ config, onSave, isDirty, showSave, fontOptions = DEFAULT_FONT_OPTIONS, direction = 'ltr' }) {
|
|
67
77
|
const [editor] = useLexicalComposerContext();
|
|
68
78
|
const [state, setState] = useState(INITIAL_STATE);
|
|
69
79
|
const updateToolbar = useCallback(() => {
|
|
@@ -82,6 +92,8 @@ export function Toolbar({ config, onSave, isDirty, showSave }) {
|
|
|
82
92
|
else if ($isQuoteNode(element)) {
|
|
83
93
|
blockType = 'quote';
|
|
84
94
|
}
|
|
95
|
+
const fontFamily = $getSelectionStyleValueForProperty(selection, 'font-family', UNSET_STYLE);
|
|
96
|
+
const fontSize = $getSelectionStyleValueForProperty(selection, 'font-size', UNSET_STYLE);
|
|
85
97
|
const linkNode = $findMatchingParent(anchorNode, $isLinkNode);
|
|
86
98
|
const isLink = Boolean(linkNode);
|
|
87
99
|
const linkUrl = linkNode && $isLinkNode(linkNode) ? linkNode.getURL() : '';
|
|
@@ -101,6 +113,8 @@ export function Toolbar({ config, onSave, isDirty, showSave }) {
|
|
|
101
113
|
activeFormats,
|
|
102
114
|
elementFormat,
|
|
103
115
|
listType,
|
|
116
|
+
fontFamily,
|
|
117
|
+
fontSize,
|
|
104
118
|
isLink,
|
|
105
119
|
linkUrl,
|
|
106
120
|
}));
|
|
@@ -183,6 +197,43 @@ export function Toolbar({ config, onSave, isDirty, showSave }) {
|
|
|
183
197
|
action();
|
|
184
198
|
editor.focus();
|
|
185
199
|
};
|
|
200
|
+
const [imageDialogOpen, setImageDialogOpen] = useState(false);
|
|
201
|
+
const openImageDialog = () => {
|
|
202
|
+
// Snapshot first: the dialog's focus trap makes Lexical drop the selection.
|
|
203
|
+
snapshotSelection();
|
|
204
|
+
setImageDialogOpen(true);
|
|
205
|
+
};
|
|
206
|
+
const closeImageDialog = () => {
|
|
207
|
+
setImageDialogOpen(false);
|
|
208
|
+
editor.focus();
|
|
209
|
+
};
|
|
210
|
+
const insertImage = (image) => {
|
|
211
|
+
const saved = menuSelectionRef.current;
|
|
212
|
+
if (saved)
|
|
213
|
+
editor.update(() => $setSelection(saved.clone()), { discrete: true });
|
|
214
|
+
editor.update(() => {
|
|
215
|
+
$insertNodeToNearestRoot($createImageNode(image));
|
|
216
|
+
});
|
|
217
|
+
closeImageDialog();
|
|
218
|
+
};
|
|
219
|
+
/** Applies a font property per `config.font.scope`: to the selection, to
|
|
220
|
+
* every text node in the document, or (for 'both') to the selection when
|
|
221
|
+
* there is a range and to the whole document when there isn't. */
|
|
222
|
+
const applyFont = (property, value) => {
|
|
223
|
+
editor.update(() => {
|
|
224
|
+
const selection = $getSelection();
|
|
225
|
+
const scope = config.font.scope;
|
|
226
|
+
const wholeDocument = scope === 'document' || (scope === 'both' && (!$isRangeSelection(selection) || selection.isCollapsed()));
|
|
227
|
+
if (wholeDocument) {
|
|
228
|
+
for (const node of $getRoot().getAllTextNodes()) {
|
|
229
|
+
node.setStyle(setStyleProperty(node.getStyle(), property, value));
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
else if ($isRangeSelection(selection)) {
|
|
233
|
+
$patchStyleText(selection, { [property]: value });
|
|
234
|
+
}
|
|
235
|
+
});
|
|
236
|
+
};
|
|
186
237
|
const [linkDialogOpen, setLinkDialogOpen] = useState(false);
|
|
187
238
|
const [linkNeedsText, setLinkNeedsText] = useState(false);
|
|
188
239
|
const openLinkDialog = useCallback(() => {
|
|
@@ -321,6 +372,30 @@ export function Toolbar({ config, onSave, isDirty, showSave }) {
|
|
|
321
372
|
const showStubInsertGroup = config.images.linked || config.images.embedded || config.poetry.enabled;
|
|
322
373
|
const showLanguageGroup = config.language.autocorrect || config.language.textCleanup || config.language.spellCheck;
|
|
323
374
|
const showOverflowMenu = fmt.strikethrough || fmt.superscript || fmt.subscript || fmt.caseTransforms || fmt.clearFormatting || config.indent;
|
|
375
|
+
// Grouped for the dropdown when options declare groups (Latin / Urdu).
|
|
376
|
+
const fontData = (() => {
|
|
377
|
+
const data = [];
|
|
378
|
+
const groups = new Map();
|
|
379
|
+
for (const f of fontOptions) {
|
|
380
|
+
const item = { value: f.family, label: f.name };
|
|
381
|
+
if (!f.group) {
|
|
382
|
+
data.push(item);
|
|
383
|
+
continue;
|
|
384
|
+
}
|
|
385
|
+
let group = groups.get(f.group);
|
|
386
|
+
if (!group) {
|
|
387
|
+
group = { group: f.group, items: [] };
|
|
388
|
+
groups.set(f.group, group);
|
|
389
|
+
data.push(group);
|
|
390
|
+
}
|
|
391
|
+
group.items.push(item);
|
|
392
|
+
}
|
|
393
|
+
return data;
|
|
394
|
+
})();
|
|
395
|
+
// Unstyled text shows the canvas default (pre-selected).
|
|
396
|
+
const canvasDefaults = CANVAS_FONT_DEFAULTS[direction];
|
|
397
|
+
const shownFontFamily = state.fontFamily === UNSET_STYLE ? canvasDefaults.family : state.fontFamily;
|
|
398
|
+
const shownFontSize = state.fontSize === UNSET_STYLE ? canvasDefaults.size : state.fontSize;
|
|
324
399
|
const formattingOptions = [
|
|
325
400
|
{ value: 'paragraph', label: 'Paragraph' },
|
|
326
401
|
...headingLevels.map((level) => ({ value: `h${level}`, label: `Heading ${level}` })),
|
|
@@ -339,7 +414,7 @@ export function Toolbar({ config, onSave, isDirty, showSave }) {
|
|
|
339
414
|
];
|
|
340
415
|
const formattingValue = state.listType ?? state.blockType;
|
|
341
416
|
const AlignIcon = ALIGN_ICONS[state.elementFormat] ?? IconAlignLeft;
|
|
342
|
-
return (_jsxs("div", { className: "likhari-toolbar", role: "toolbar", "aria-label": "Formatting", children: [showSave && (_jsx("div", { className: "likhari-toolbar-group", children: _jsx(ToolbarButton, { icon: IconDeviceFloppy, title: "Save", dirty: Boolean(isDirty), onClick: onSave }) })), config.history && (_jsxs("div", { className: "likhari-toolbar-group", children: [_jsx(ToolbarButton, { icon: IconArrowBackUp, title: "Undo", disabled: !state.canUndo, onClick: () => editor.dispatchCommand(UNDO_COMMAND, undefined) }), _jsx(ToolbarButton, { icon: IconArrowForwardUp, title: "Redo", disabled: !state.canRedo, onClick: () => editor.dispatchCommand(REDO_COMMAND, undefined) })] })), showFormattingGroup && (_jsx("div", { className: "likhari-toolbar-group", children: _jsx(ToolbarSelect, { icon: IconPilcrow, label: "Formatting", width: 148, value: formattingValue, data: formattingOptions, onChange: withRefocus((v) => applyFormatting(v)) }) })), showInlineGroup && (_jsxs("div", { className: "likhari-toolbar-group", children: [fmt.bold && (_jsx(ToolbarButton, { icon: IconBold, title: "Bold", active: state.activeFormats.has('bold'), onClick: () => formatText('bold') })), fmt.italic && (_jsx(ToolbarButton, { icon: IconItalic, title: "Italic", active: state.activeFormats.has('italic'), onClick: () => formatText('italic') })), fmt.underline && (_jsx(ToolbarButton, { icon: IconUnderline, title: "Underline", active: state.activeFormats.has('underline'), onClick: () => formatText('underline') }))] })), (config.font.family || config.font.size) && (_jsxs("div", { className: "likhari-toolbar-group
|
|
417
|
+
return (_jsxs("div", { className: "likhari-toolbar", role: "toolbar", "aria-label": "Formatting", children: [showSave && (_jsx("div", { className: "likhari-toolbar-group", children: _jsx(ToolbarButton, { icon: IconDeviceFloppy, title: "Save", dirty: Boolean(isDirty), onClick: onSave }) })), config.history && (_jsxs("div", { className: "likhari-toolbar-group", children: [_jsx(ToolbarButton, { icon: IconArrowBackUp, title: "Undo", disabled: !state.canUndo, onClick: () => editor.dispatchCommand(UNDO_COMMAND, undefined) }), _jsx(ToolbarButton, { icon: IconArrowForwardUp, title: "Redo", disabled: !state.canRedo, onClick: () => editor.dispatchCommand(REDO_COMMAND, undefined) })] })), showFormattingGroup && (_jsx("div", { className: "likhari-toolbar-group", children: _jsx(ToolbarSelect, { icon: IconPilcrow, label: "Formatting", width: 148, value: formattingValue, data: formattingOptions, onChange: withRefocus((v) => applyFormatting(v)) }) })), showInlineGroup && (_jsxs("div", { className: "likhari-toolbar-group", children: [fmt.bold && (_jsx(ToolbarButton, { icon: IconBold, title: "Bold", active: state.activeFormats.has('bold'), onClick: () => formatText('bold') })), fmt.italic && (_jsx(ToolbarButton, { icon: IconItalic, title: "Italic", active: state.activeFormats.has('italic'), onClick: () => formatText('italic') })), fmt.underline && (_jsx(ToolbarButton, { icon: IconUnderline, title: "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: "Font family", width: 150, value: fontOptions.some((f) => f.family === shownFontFamily) ? shownFontFamily : null, placeholder: "Font", data: fontData, searchable: true, 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: "Font size", width: 84, value: FONT_SIZES_PX.some((px) => `${px}px` === shownFontSize) ? shownFontSize : null, placeholder: "Size", 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: "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 ? (
|
|
343
418
|
// Caret is in a link: the button opens a menu to see, edit or remove it.
|
|
344
419
|
_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": "Link options", title: "Link options", onMouseDown: (e) => e.preventDefault(), children: _jsx(IconLink, { size: ICON_SIZE, stroke: ICON_STROKE }) }) }), _jsx(Menu.Dropdown, { children: _jsx(LinkMenuItems, { url: state.linkUrl, onEdit: () => {
|
|
345
420
|
restoreSelection();
|
|
@@ -350,5 +425,5 @@ export function Toolbar({ config, onSave, isDirty, showSave }) {
|
|
|
350
425
|
}, 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: () => {
|
|
351
426
|
restoreSelection();
|
|
352
427
|
openLinkDialog();
|
|
353
|
-
}, onRemove: removeLink }) })] })), showStubInsertGroup && (_jsxs("div", { className: "likhari-toolbar-group likhari-toolbar-group--collapse-tablet", children: [(config.images.linked || config.images.embedded) && _jsx(
|
|
428
|
+
}, onRemove: removeLink }) })] })), showStubInsertGroup && (_jsxs("div", { className: "likhari-toolbar-group likhari-toolbar-group--collapse-tablet", children: [(config.images.linked || config.images.embedded) && (_jsx(ToolbarButton, { icon: IconPhoto, title: "Insert image", onClick: openImageDialog })), config.poetry.enabled && _jsx(StubButton, { icon: IconFeather, title: "Poetry blocks" })] })), showLanguageGroup && (_jsxs("div", { className: "likhari-toolbar-group likhari-toolbar-group--collapse-tablet", children: [config.language.autocorrect && _jsx(StubButton, { icon: IconWand, title: "Auto-correct" }), config.language.textCleanup && _jsx(StubButton, { icon: IconSparkles, title: "Text cleanup" }), config.language.spellCheck && _jsx(StubButton, { icon: IconAbc, title: "Spell-checker" })] })), 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": "More formatting", title: "More formatting", onMouseDown: (e) => e.preventDefault(), children: _jsx(IconDots, { size: ICON_SIZE, stroke: ICON_STROKE }) }) }), _jsxs(Menu.Dropdown, { children: [fmt.strikethrough && (_jsx(OverflowItem, { icon: IconStrikethrough, label: "Strikethrough", active: state.activeFormats.has('strikethrough'), onClick: runOverflowAction(() => formatText('strikethrough')) })), fmt.superscript && (_jsx(OverflowItem, { icon: IconSuperscript, label: "Superscript", active: state.activeFormats.has('superscript'), onClick: runOverflowAction(() => formatText('superscript')) })), fmt.subscript && (_jsx(OverflowItem, { icon: IconSubscript, label: "Subscript", active: state.activeFormats.has('subscript'), onClick: runOverflowAction(() => formatText('subscript')) })), fmt.caseTransforms && (_jsxs(_Fragment, { children: [_jsx(OverflowItem, { icon: IconLetterCaseUpper, label: "UPPERCASE", onClick: runOverflowAction(() => applyCaseTransform('upper')) }), _jsx(OverflowItem, { icon: IconLetterCaseLower, label: "lowercase", onClick: runOverflowAction(() => applyCaseTransform('lower')) }), _jsx(OverflowItem, { icon: IconLetterCase, label: "Capitalize", onClick: runOverflowAction(() => applyCaseTransform('capitalize')) })] })), fmt.clearFormatting && _jsx(OverflowItem, { icon: IconClearFormatting, label: "Clear formatting", onClick: runOverflowAction(clearFormatting) }), config.indent && (_jsxs(_Fragment, { children: [_jsx(OverflowItem, { icon: IconIndentDecrease, label: "Outdent", onClick: runOverflowAction(() => editor.dispatchCommand(OUTDENT_CONTENT_COMMAND, undefined)) }), _jsx(OverflowItem, { icon: IconIndentIncrease, label: "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 }))] }));
|
|
354
429
|
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { type FontOption } from './urduWebFonts.js';
|
|
2
|
+
export { URDU_WEB_FONT_OPTIONS, injectUrduWebFontsCss, type FontOption } from './urduWebFonts.js';
|
|
3
|
+
/** Three generic Latin faces plus the full urdu-web-fonts collection. */
|
|
4
|
+
export declare const DEFAULT_FONT_OPTIONS: FontOption[];
|
|
5
|
+
/** Sizes offered by the toolbar's font-size dropdown, in px. */
|
|
6
|
+
export declare const FONT_SIZES_PX: number[];
|
|
7
|
+
/**
|
|
8
|
+
* What the canvas renders when text has no explicit font style — kept in sync
|
|
9
|
+
* with `.likhari-content-editable` in theme/editor.css. The toolbar's font
|
|
10
|
+
* dropdowns show these as the pre-selected values.
|
|
11
|
+
*/
|
|
12
|
+
export declare const CANVAS_FONT_DEFAULTS: {
|
|
13
|
+
readonly ltr: {
|
|
14
|
+
readonly family: string;
|
|
15
|
+
readonly size: "15px";
|
|
16
|
+
};
|
|
17
|
+
readonly rtl: {
|
|
18
|
+
readonly family: "\"Noto Nastaliq Urdu\", serif";
|
|
19
|
+
readonly size: "20px";
|
|
20
|
+
};
|
|
21
|
+
};
|
|
22
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/fonts/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAyB,KAAK,UAAU,EAAE,MAAM,gBAAgB,CAAC;AAExE,OAAO,EAAE,qBAAqB,EAAE,qBAAqB,EAAE,KAAK,UAAU,EAAE,MAAM,gBAAgB,CAAC;AAE/F,yEAAyE;AACzE,eAAO,MAAM,oBAAoB,EAAE,UAAU,EAK5C,CAAC;AAEF,gEAAgE;AAChE,eAAO,MAAM,aAAa,UAAmD,CAAC;AAE9E;;;;GAIG;AACH,eAAO,MAAM,oBAAoB;;;;;;;;;CAGvB,CAAC"}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { URDU_WEB_FONT_OPTIONS } from './urduWebFonts.js';
|
|
2
|
+
export { URDU_WEB_FONT_OPTIONS, injectUrduWebFontsCss } from './urduWebFonts.js';
|
|
3
|
+
/** Three generic Latin faces plus the full urdu-web-fonts collection. */
|
|
4
|
+
export const DEFAULT_FONT_OPTIONS = [
|
|
5
|
+
{ name: 'Serif', family: `'IBM Plex Serif', Georgia, serif`, group: 'Latin' },
|
|
6
|
+
{ name: 'Sans', family: `'IBM Plex Sans', system-ui, sans-serif`, group: 'Latin' },
|
|
7
|
+
{ name: 'Mono', family: `ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace`, group: 'Latin' },
|
|
8
|
+
...URDU_WEB_FONT_OPTIONS,
|
|
9
|
+
];
|
|
10
|
+
/** Sizes offered by the toolbar's font-size dropdown, in px. */
|
|
11
|
+
export const FONT_SIZES_PX = [12, 13, 14, 15, 16, 18, 20, 24, 28, 32, 36, 48];
|
|
12
|
+
/**
|
|
13
|
+
* What the canvas renders when text has no explicit font style — kept in sync
|
|
14
|
+
* with `.likhari-content-editable` in theme/editor.css. The toolbar's font
|
|
15
|
+
* dropdowns show these as the pre-selected values.
|
|
16
|
+
*/
|
|
17
|
+
export const CANVAS_FONT_DEFAULTS = {
|
|
18
|
+
ltr: { family: DEFAULT_FONT_OPTIONS[0].family, size: '15px' },
|
|
19
|
+
rtl: { family: `"Noto Nastaliq Urdu", serif`, size: '20px' },
|
|
20
|
+
};
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/** One entry in the toolbar's font-family dropdown. */
|
|
2
|
+
export interface FontOption {
|
|
3
|
+
/** Label shown in the dropdown */
|
|
4
|
+
name: string;
|
|
5
|
+
/** CSS font-family value stored on the selected text, e.g. `"Amiri", serif` */
|
|
6
|
+
family: string;
|
|
7
|
+
/** Optional dropdown group heading */
|
|
8
|
+
group?: string;
|
|
9
|
+
}
|
|
10
|
+
export declare const URDU_WEB_FONT_OPTIONS: FontOption[];
|
|
11
|
+
/** Registers every urdu-web-fonts @font-face via a <link> per family. Safe to
|
|
12
|
+
* call repeatedly and from several editors: it injects once per page. */
|
|
13
|
+
export declare function injectUrduWebFontsCss(): void;
|
|
14
|
+
//# sourceMappingURL=urduWebFonts.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"urduWebFonts.d.ts","sourceRoot":"","sources":["../../src/fonts/urduWebFonts.ts"],"names":[],"mappings":"AAqDA,uDAAuD;AACvD,MAAM,WAAW,UAAU;IACzB,kCAAkC;IAClC,IAAI,EAAE,MAAM,CAAC;IACb,+EAA+E;IAC/E,MAAM,EAAE,MAAM,CAAC;IACf,sCAAsC;IACtC,KAAK,CAAC,EAAE,MAAM,CAAC;CAChB;AAED,eAAO,MAAM,qBAAqB,EAAE,UAAU,EAI3C,CAAC;AAIJ;yEACyE;AACzE,wBAAgB,qBAAqB,IAAI,IAAI,CAc5C"}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Loads the Urdu/Arabic-script font collection from the
|
|
3
|
+
* inshapardaz/urdu-web-fonts GitHub repository through jsDelivr's GitHub
|
|
4
|
+
* CDN — the same source, and the same pinned commit, that the qari EPUB
|
|
5
|
+
* reader uses, so both apps offer an identical font set. Nothing is bundled
|
|
6
|
+
* or added as an npm dependency.
|
|
7
|
+
*
|
|
8
|
+
* Pinned to a commit (not a branch) so the fonts served don't change under
|
|
9
|
+
* consumers unexpectedly; bump URDU_WEB_FONTS_COMMIT to pick up upstream
|
|
10
|
+
* additions. Each family's stylesheet only registers its @font-face rules —
|
|
11
|
+
* the browser downloads a font file when that font is actually used.
|
|
12
|
+
*/
|
|
13
|
+
const URDU_WEB_FONTS_COMMIT = '571e360d5c272252efb0b0b21af54d1f63457521';
|
|
14
|
+
const URDU_WEB_FONTS_BASE_URL = `https://cdn.jsdelivr.net/gh/inshapardaz/urdu-web-fonts@${URDU_WEB_FONTS_COMMIT}/src/fonts`;
|
|
15
|
+
const URDU_WEB_FONTS = [
|
|
16
|
+
{ dir: 'adobe-arabic', name: 'Adobe Arabic', family: 'AdobeArabic' },
|
|
17
|
+
{ dir: 'alvi-lahori-nastalique', name: 'Alvi Lahori Nastaleeq', family: 'AlviLahoriNastaleeq' },
|
|
18
|
+
{ dir: 'amiri', name: 'Amiri', family: 'Amiri' },
|
|
19
|
+
{ dir: 'aref-ruqqa', name: 'Aref Ruqaa', family: 'Aref Ruqaa' },
|
|
20
|
+
{ dir: 'dehalvi-khushkhat', name: 'Dehalvi Khush Khat', family: 'DehalviKhushKhat' },
|
|
21
|
+
{ dir: 'dubai', name: 'Dubai', family: 'Dubai' },
|
|
22
|
+
{ dir: 'emad-nastaleeq', name: 'Emad Nastaleeq', family: 'EmadNastaleeq' },
|
|
23
|
+
{ dir: 'fajer-noori-nastalique', name: 'Fajer Noori Nastalique', family: 'FajerNooriNastalique' },
|
|
24
|
+
{ dir: 'gulzar-nastalique', name: 'Gulzar Nastalique', family: 'gulzar-nastalique' },
|
|
25
|
+
{ dir: 'jameel-khushkhati', name: 'Jameel Khushkhati', family: 'jameel-khushkhati' },
|
|
26
|
+
{ dir: 'jameel-noori-kasheeda', name: 'Jameel Noori Nastaleeq Kasheeda', family: 'JameelNooriNastaleeqKasheeda' },
|
|
27
|
+
{ dir: 'jameel-noori-nastalique', name: 'Jameel Noori Nastaleeq', family: 'JameelNooriNastaleeq' },
|
|
28
|
+
{ dir: 'lalezar', name: 'Lalezar', family: 'Lalezar' },
|
|
29
|
+
{ dir: 'lateef', name: 'Lateef', family: 'Lateef' },
|
|
30
|
+
{ dir: 'mada', name: 'Mada', family: 'Mada' },
|
|
31
|
+
{ dir: 'mehr-nastalique', name: 'Mehr Nastaleeq', family: 'MehrNastaleeq' },
|
|
32
|
+
{ dir: 'mehr-nastalique-2', name: 'Mehr Nastaliq Web', family: 'Mehr Nastaliq Web' },
|
|
33
|
+
{ dir: 'nafees-nastaleeq', name: 'Nafees Nastaleeq', family: 'NafeesNastaleeq' },
|
|
34
|
+
{ dir: 'Nafees-web-naskh', name: 'Nafees Web Naskh', family: 'NafeesWebNaskh' },
|
|
35
|
+
{ dir: 'noto-naskh', name: 'Noto Naskh Arabic', family: 'Noto Naskh Arabic' },
|
|
36
|
+
{ dir: 'noto-nastalique', name: 'Noto Nastaliq Urdu', family: 'Noto Nastaliq Urdu' },
|
|
37
|
+
{ dir: 'pak-nastaleeq', name: 'Pak Nastaleeq', family: 'PakNastaleeq' },
|
|
38
|
+
{ dir: 'qahiri', name: 'Qahiri', family: 'Qahiri' },
|
|
39
|
+
{ dir: 'reem-kufi', name: 'Reem Kufi', family: 'Reem Kufi' },
|
|
40
|
+
{ dir: 'sameer-khashab-bold', name: 'Sameer Khashab Bold', family: 'sameer-khashab' },
|
|
41
|
+
{ dir: 'scheherazade', name: 'Scheherazade New', family: 'Scheherazade New' },
|
|
42
|
+
];
|
|
43
|
+
export const URDU_WEB_FONT_OPTIONS = URDU_WEB_FONTS.map((font) => ({
|
|
44
|
+
name: font.name,
|
|
45
|
+
family: `"${font.family}", serif`,
|
|
46
|
+
group: 'Urdu / Arabic script',
|
|
47
|
+
}));
|
|
48
|
+
let injected = false;
|
|
49
|
+
/** Registers every urdu-web-fonts @font-face via a <link> per family. Safe to
|
|
50
|
+
* call repeatedly and from several editors: it injects once per page. */
|
|
51
|
+
export function injectUrduWebFontsCss() {
|
|
52
|
+
if (injected || typeof document === 'undefined')
|
|
53
|
+
return;
|
|
54
|
+
if (document.querySelector('link[data-likhari-urdu-fonts]')) {
|
|
55
|
+
injected = true;
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
for (const font of URDU_WEB_FONTS) {
|
|
59
|
+
const link = document.createElement('link');
|
|
60
|
+
link.rel = 'stylesheet';
|
|
61
|
+
link.href = `${URDU_WEB_FONTS_BASE_URL}/${font.dir}/stylesheet.css`;
|
|
62
|
+
link.setAttribute('data-likhari-urdu-fonts', font.dir);
|
|
63
|
+
document.head.appendChild(link);
|
|
64
|
+
}
|
|
65
|
+
injected = true;
|
|
66
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { type NodeKey } from 'lexical';
|
|
2
|
+
import type { ImageLinkType } from './ImageNode.js';
|
|
3
|
+
interface ImageComponentProps {
|
|
4
|
+
nodeKey: NodeKey;
|
|
5
|
+
src: string;
|
|
6
|
+
altText: string;
|
|
7
|
+
caption: string | null;
|
|
8
|
+
linkType: ImageLinkType;
|
|
9
|
+
/** Display width in px; null = natural size (height always follows the aspect ratio). */
|
|
10
|
+
width: number | null;
|
|
11
|
+
height: number | null;
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* An image in the document. Click selects it (Delete/Backspace removes it),
|
|
15
|
+
* drag the corner handle to resize, double-click or right-click for the edit
|
|
16
|
+
* dialog and menu (edit image, add/edit/remove caption, delete). The caption
|
|
17
|
+
* is shown as plain text under the image — it is edited in the dialog, not in
|
|
18
|
+
* place.
|
|
19
|
+
*/
|
|
20
|
+
export declare function ImageComponent({ nodeKey, src, altText, caption, linkType, width, height }: ImageComponentProps): import("react").JSX.Element;
|
|
21
|
+
export {};
|
|
22
|
+
//# sourceMappingURL=ImageComponent.d.ts.map
|
|
@@ -0,0 +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;AAGjB,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;;;;;;GAMG;AACH,wBAAgB,cAAc,CAAC,EAAE,OAAO,EAAE,GAAG,EAAE,OAAO,EAAE,OAAO,EAAE,QAAQ,EAAE,KAAK,EAAE,MAAM,EAAE,EAAE,mBAAmB,+BAsM9G"}
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { useCallback, useEffect, useRef, useState } from 'react';
|
|
3
|
+
import { Menu } from '@mantine/core';
|
|
4
|
+
import { IconPhotoEdit, IconTextCaption, IconTrash, IconX } from '@tabler/icons-react';
|
|
5
|
+
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext';
|
|
6
|
+
import { useLexicalNodeSelection } from '@lexical/react/useLexicalNodeSelection';
|
|
7
|
+
import { $getNodeByKey, $getSelection, $isNodeSelection, CLICK_COMMAND, COMMAND_PRIORITY_LOW, KEY_BACKSPACE_COMMAND, KEY_DELETE_COMMAND, } from 'lexical';
|
|
8
|
+
import { ImageDialog } from './ImageDialog.js';
|
|
9
|
+
import { useImageOptions } from './ImageOptionsContext.js';
|
|
10
|
+
// A type-only import of ImageNode (ImageNode itself imports this file), so the
|
|
11
|
+
// node is recognised by its type string rather than `instanceof`.
|
|
12
|
+
function isImageNode(node) {
|
|
13
|
+
return node?.getType() === 'image';
|
|
14
|
+
}
|
|
15
|
+
const MIN_WIDTH = 32;
|
|
16
|
+
/**
|
|
17
|
+
* An image in the document. Click selects it (Delete/Backspace removes it),
|
|
18
|
+
* drag the corner handle to resize, double-click or right-click for the edit
|
|
19
|
+
* dialog and menu (edit image, add/edit/remove caption, delete). The caption
|
|
20
|
+
* is shown as plain text under the image — it is edited in the dialog, not in
|
|
21
|
+
* place.
|
|
22
|
+
*/
|
|
23
|
+
export function ImageComponent({ nodeKey, src, altText, caption, linkType, width, height }) {
|
|
24
|
+
const [editor] = useLexicalComposerContext();
|
|
25
|
+
const options = useImageOptions();
|
|
26
|
+
const [isSelected, setSelected, clearSelection] = useLexicalNodeSelection(nodeKey);
|
|
27
|
+
const imageRef = useRef(null);
|
|
28
|
+
const figureRef = useRef(null);
|
|
29
|
+
const [menu, setMenu] = useState(null);
|
|
30
|
+
const [dialog, setDialog] = useState(null);
|
|
31
|
+
const [liveWidth, setLiveWidth] = useState(null);
|
|
32
|
+
// Selecting must go through CLICK_COMMAND (returning true) rather than a
|
|
33
|
+
// React onClick: otherwise Lexical's own click handling turns the DOM
|
|
34
|
+
// selection into a range selection right after, undoing the node selection.
|
|
35
|
+
useEffect(() => {
|
|
36
|
+
return editor.registerCommand(CLICK_COMMAND, (event) => {
|
|
37
|
+
if (event.target !== imageRef.current)
|
|
38
|
+
return false;
|
|
39
|
+
clearSelection();
|
|
40
|
+
setSelected(true);
|
|
41
|
+
return true;
|
|
42
|
+
}, COMMAND_PRIORITY_LOW);
|
|
43
|
+
}, [editor, clearSelection, setSelected]);
|
|
44
|
+
const removeImage = useCallback(() => {
|
|
45
|
+
editor.update(() => {
|
|
46
|
+
$getNodeByKey(nodeKey)?.remove();
|
|
47
|
+
});
|
|
48
|
+
}, [editor, nodeKey]);
|
|
49
|
+
const onDelete = useCallback((event) => {
|
|
50
|
+
if (!isSelected || dialog || menu)
|
|
51
|
+
return false;
|
|
52
|
+
if (!$isNodeSelection($getSelection()))
|
|
53
|
+
return false;
|
|
54
|
+
event.preventDefault();
|
|
55
|
+
$getNodeByKey(nodeKey)?.remove();
|
|
56
|
+
return true;
|
|
57
|
+
}, [isSelected, dialog, menu, nodeKey]);
|
|
58
|
+
useEffect(() => {
|
|
59
|
+
const unregisterDelete = editor.registerCommand(KEY_DELETE_COMMAND, onDelete, COMMAND_PRIORITY_LOW);
|
|
60
|
+
const unregisterBackspace = editor.registerCommand(KEY_BACKSPACE_COMMAND, onDelete, COMMAND_PRIORITY_LOW);
|
|
61
|
+
return () => {
|
|
62
|
+
unregisterDelete();
|
|
63
|
+
unregisterBackspace();
|
|
64
|
+
};
|
|
65
|
+
}, [editor, onDelete]);
|
|
66
|
+
const updateNode = (change) => {
|
|
67
|
+
editor.update(() => {
|
|
68
|
+
const node = $getNodeByKey(nodeKey);
|
|
69
|
+
if (isImageNode(node))
|
|
70
|
+
change(node);
|
|
71
|
+
});
|
|
72
|
+
};
|
|
73
|
+
const applyEdit = (value) => {
|
|
74
|
+
updateNode((node) => {
|
|
75
|
+
node.setSource(value.src, value.linkType);
|
|
76
|
+
node.setAltText(value.altText);
|
|
77
|
+
node.setCaption(value.caption);
|
|
78
|
+
node.setDimensions(value.width, value.height);
|
|
79
|
+
});
|
|
80
|
+
setDialog(null);
|
|
81
|
+
editor.focus();
|
|
82
|
+
};
|
|
83
|
+
// --- drag-to-resize: bottom-right handle, aspect ratio kept ---------------
|
|
84
|
+
const resizeStart = useRef(null);
|
|
85
|
+
const onResizeDown = (event) => {
|
|
86
|
+
const img = imageRef.current;
|
|
87
|
+
if (!img)
|
|
88
|
+
return;
|
|
89
|
+
event.preventDefault();
|
|
90
|
+
event.stopPropagation();
|
|
91
|
+
event.currentTarget.setPointerCapture(event.pointerId);
|
|
92
|
+
const box = img.getBoundingClientRect();
|
|
93
|
+
resizeStart.current = {
|
|
94
|
+
x: event.clientX,
|
|
95
|
+
width: box.width,
|
|
96
|
+
ratio: img.naturalHeight && img.naturalWidth ? img.naturalHeight / img.naturalWidth : box.height / box.width,
|
|
97
|
+
max: figureRef.current?.clientWidth ?? box.width,
|
|
98
|
+
};
|
|
99
|
+
setLiveWidth(box.width);
|
|
100
|
+
};
|
|
101
|
+
const onResizeMove = (event) => {
|
|
102
|
+
const start = resizeStart.current;
|
|
103
|
+
if (!start)
|
|
104
|
+
return;
|
|
105
|
+
setLiveWidth(Math.round(Math.min(Math.max(start.width + (event.clientX - start.x), MIN_WIDTH), start.max)));
|
|
106
|
+
};
|
|
107
|
+
const onResizeUp = () => {
|
|
108
|
+
const start = resizeStart.current;
|
|
109
|
+
resizeStart.current = null;
|
|
110
|
+
if (start && liveWidth !== null) {
|
|
111
|
+
const w = Math.round(liveWidth);
|
|
112
|
+
updateNode((node) => node.setDimensions(w, Math.round(w * start.ratio)));
|
|
113
|
+
}
|
|
114
|
+
setLiveWidth(null);
|
|
115
|
+
};
|
|
116
|
+
const shownWidth = liveWidth ?? width ?? undefined;
|
|
117
|
+
return (_jsxs("figure", { ref: figureRef, className: "likhari-image-figure", "data-selected": isSelected ? 'true' : 'false', contentEditable: false, onDoubleClick: (e) => {
|
|
118
|
+
if (e.target === imageRef.current)
|
|
119
|
+
setDialog({});
|
|
120
|
+
}, onContextMenu: (e) => {
|
|
121
|
+
e.preventDefault();
|
|
122
|
+
clearSelection();
|
|
123
|
+
setSelected(true);
|
|
124
|
+
setMenu({ x: e.clientX, y: e.clientY });
|
|
125
|
+
}, children: [_jsxs("span", { className: "likhari-image-wrap", children: [_jsx("img", { ref: imageRef, className: "likhari-image", src: src, alt: altText, width: shownWidth, draggable: false }), isSelected && (_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) => {
|
|
126
|
+
if (!opened)
|
|
127
|
+
setMenu(null);
|
|
128
|
+
}, 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" }), options.allowCaption && (_jsx(Menu.Item, { leftSection: _jsx(IconTextCaption, { size: 16, stroke: 1.75 }), onClick: () => setDialog({ focus: '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, width, height }, initialFocus: dialog?.focus, onSubmit: applyEdit, onClose: () => {
|
|
129
|
+
setDialog(null);
|
|
130
|
+
editor.focus();
|
|
131
|
+
} })] }));
|
|
132
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
export interface ImageCropperProps {
|
|
2
|
+
/** The image as it is now (original, or already edited). */
|
|
3
|
+
src: string;
|
|
4
|
+
/** Called with the edited image as a data URI. */
|
|
5
|
+
onApply: (edited: string) => void;
|
|
6
|
+
/** Whether there are edits to undo. */
|
|
7
|
+
canReset: boolean;
|
|
8
|
+
onReset: () => void;
|
|
9
|
+
}
|
|
10
|
+
/** Rotate, flip and crop. Drag on the preview to choose the area to keep, then
|
|
11
|
+
* "Apply crop"; each operation re-renders the pixels, so it needs an image the
|
|
12
|
+
* canvas may read (uploaded files and same-origin/CORS-enabled URLs). */
|
|
13
|
+
export declare function ImageCropper({ src, onApply, canReset, onReset }: ImageCropperProps): import("react").JSX.Element;
|
|
14
|
+
//# sourceMappingURL=ImageCropper.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"ImageCropper.d.ts","sourceRoot":"","sources":["../../src/image/ImageCropper.tsx"],"names":[],"mappings":"AAgBA,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;;yEAEyE;AACzE,wBAAgB,YAAY,CAAC,EAAE,GAAG,EAAE,OAAO,EAAE,QAAQ,EAAE,OAAO,EAAE,EAAE,iBAAiB,+BA+JlF"}
|