@platecms/delta-smart-text 0.6.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (142) hide show
  1. package/.env.example +7 -0
  2. package/__generated__/fragment-masking.ts +87 -0
  3. package/__generated__/gql.ts +238 -0
  4. package/__generated__/graphql.ts +3441 -0
  5. package/__generated__/index.ts +2 -0
  6. package/codegen.config.ts +24 -0
  7. package/eslint.config.mjs +43 -0
  8. package/i18n.js +28 -0
  9. package/package.json +5 -5
  10. package/project.json +54 -0
  11. package/src/components/DeltaSlateEditor.vue +74 -0
  12. package/src/components/icon/FontAwesomeIcon.vue +21 -0
  13. package/src/graphql/apiTokens/apiTokens.fragments.gql +8 -0
  14. package/src/graphql/assets/assets.fragments.gql +10 -0
  15. package/src/graphql/blueprints/blueprints.fragments.gql +52 -0
  16. package/src/graphql/buildingBlockFieldFulfillments/buildingBlockFieldFullfillment.fragements.gql +6 -0
  17. package/src/graphql/buildingBlockFields/buildingBlockField.fragments.gql +8 -0
  18. package/src/graphql/buildingBlocks/buildingBlocks.fragments.gql +11 -0
  19. package/src/graphql/channels/channels.fragments.gql +9 -0
  20. package/src/graphql/contentExperiences/allContentExperiences.query.gql +24 -0
  21. package/src/graphql/contentExperiences/contentExperience.query.gql +20 -0
  22. package/src/graphql/contentExperiences/contentExperiences.fragments.gql +14 -0
  23. package/src/graphql/contentFields/contentFields.fragments.gql +7 -0
  24. package/src/graphql/contentItems/allContentItems.query.gql +48 -0
  25. package/src/graphql/contentItems/contentItems.fragments.gql +11 -0
  26. package/src/graphql/contentTypes/allContentTypes.query.gql +26 -0
  27. package/src/graphql/contentTypes/contentTypes.fragments.gql +11 -0
  28. package/src/graphql/contentValidations/contentValidationRule.fragments.gql +34 -0
  29. package/src/graphql/contentValues/allContentValues.query.gql +41 -0
  30. package/src/graphql/contentValues/contentValues.fragments.gql +9 -0
  31. package/src/graphql/contentValues/createContentValue.mutation.gql +17 -0
  32. package/src/graphql/experienceComponents/experienceComponent.fragments.gql +13 -0
  33. package/src/graphql/fragments.gql +6 -0
  34. package/src/graphql/gridDefinition/gridDefinition.fragments.gql +5 -0
  35. package/src/graphql/gridPlacements/gridPlacement.fragments.gql +7 -0
  36. package/src/graphql/grids/grid.fragments.gql +7 -0
  37. package/src/graphql/invitations/invitations.fragments.gql +7 -0
  38. package/src/graphql/organizations/organizations.fragments.gql +13 -0
  39. package/src/graphql/pathParts/pathParts.fragments.gql +19 -0
  40. package/src/graphql/plateMaintainers/plateMaintainer.fragements.gql +10 -0
  41. package/src/graphql/roleAssignments/roleAssignment.fragments.gql +9 -0
  42. package/src/graphql/roles/roles.fragments.gql +7 -0
  43. package/src/graphql/subject/subject.fragments.gql +8 -0
  44. package/src/graphql/tags/tags.fragments.gql +17 -0
  45. package/src/graphql/themes/themes.fragments.gql +8 -0
  46. package/src/index.css +1 -0
  47. package/src/index.ts +21 -0
  48. package/src/locales/en.json +52 -0
  49. package/src/locales/nl.json +52 -0
  50. package/src/react/components/DeltaSlateEditor.tsx +243 -0
  51. package/src/react/components/DeltaSlateEditorConnector.tsx +50 -0
  52. package/src/react/components/Element.spec.tsx +244 -0
  53. package/src/react/components/Element.tsx +151 -0
  54. package/src/react/components/FontAwesomeIcon.tsx +17 -0
  55. package/src/react/components/Leaf.spec.tsx +61 -0
  56. package/src/react/components/Leaf.tsx +22 -0
  57. package/src/react/components/elements/CodeElement.tsx +16 -0
  58. package/src/react/components/elements/ContentValueElement.tsx +33 -0
  59. package/src/react/components/elements/LinkElement.tsx +44 -0
  60. package/src/react/components/inputs/SearchInput.tsx +22 -0
  61. package/src/react/components/inputs/TextInput.tsx +30 -0
  62. package/src/react/components/menus/ContentAndFormatMenu.tsx +272 -0
  63. package/src/react/components/menus/ContentLibraryMenu.tsx +48 -0
  64. package/src/react/components/menus/ReusableContentMenu.tsx +190 -0
  65. package/src/react/components/menus/content/ContentItemsMenu.tsx +215 -0
  66. package/src/react/components/menus/content/ContentTypesMenu.tsx +129 -0
  67. package/src/react/components/menus/content/partials/ContentFieldMenuItem.tsx +11 -0
  68. package/src/react/components/menus/content/partials/ContentValueMenuItem.tsx +58 -0
  69. package/src/react/components/menus/link/AnchorInput.tsx +123 -0
  70. package/src/react/components/menus/link/LinkInput.tsx +195 -0
  71. package/src/react/components/menus/link/LinkMenu.spec.tsx +145 -0
  72. package/src/react/components/menus/link/LinkMenu.tsx +289 -0
  73. package/src/react/components/menus/partials/MenuButton.tsx +52 -0
  74. package/src/react/components/menus/partials/MenuContainer.tsx +9 -0
  75. package/src/react/components/menus/partials/MenuHeader.tsx +11 -0
  76. package/src/react/components/toolbar/Toolbar.tsx +249 -0
  77. package/src/react/components/toolbar/ToolbarBlockButton.tsx +31 -0
  78. package/src/react/components/toolbar/ToolbarHeadingDropdownButton.tsx +76 -0
  79. package/src/react/components/toolbar/ToolbarLinkButton.tsx +33 -0
  80. package/src/react/components/toolbar/ToolbarMarkButton.tsx +25 -0
  81. package/src/react/components/toolbar/content/ContentExtractToolbarButton.tsx +68 -0
  82. package/src/react/components/toolbar/content/ContentLibraryToolbarButton.tsx +43 -0
  83. package/src/react/components/toolbar/content/ContentToolbar.tsx +37 -0
  84. package/src/react/components/toolbar/link/ToolbarDisplayLink.tsx +36 -0
  85. package/src/react/components/toolbar/link/UnlinkButton.tsx +25 -0
  86. package/src/react/config/hotkeys.ts +8 -0
  87. package/src/react/plugins/index.ts +59 -0
  88. package/src/react/store/editorSlice.ts +124 -0
  89. package/src/react/store/store.ts +12 -0
  90. package/src/react/types.ts +87 -0
  91. package/src/react/utils/decorator.ts +61 -0
  92. package/src/react/utils/index.ts +110 -0
  93. package/src/vue-shims.d.ts +5 -0
  94. package/tsconfig.json +26 -0
  95. package/tsconfig.lib.json +25 -0
  96. package/tsconfig.spec.json +22 -0
  97. package/vite.config.ts +67 -0
  98. package/components/DeltaSlateEditor.vue.d.ts +0 -26
  99. package/index.cjs +0 -381
  100. package/index.css +0 -1
  101. package/index.d.ts +0 -12
  102. package/index.js +0 -49254
  103. package/react/components/DeltaSlateEditor.d.ts +0 -7
  104. package/react/components/DeltaSlateEditorConnector.d.ts +0 -12
  105. package/react/components/Element.d.ts +0 -8
  106. package/react/components/FontAwesomeIcon.d.ts +0 -6
  107. package/react/components/Leaf.d.ts +0 -3
  108. package/react/components/elements/CodeElement.d.ts +0 -8
  109. package/react/components/elements/ContentValueElement.d.ts +0 -8
  110. package/react/components/elements/LinkElement.d.ts +0 -8
  111. package/react/components/inputs/SearchInput.d.ts +0 -5
  112. package/react/components/inputs/TextInput.d.ts +0 -7
  113. package/react/components/menus/ContentAndFormatMenu.d.ts +0 -10
  114. package/react/components/menus/ContentLibraryMenu.d.ts +0 -4
  115. package/react/components/menus/ReusableContentMenu.d.ts +0 -3
  116. package/react/components/menus/content/ContentItemsMenu.d.ts +0 -5
  117. package/react/components/menus/content/ContentTypesMenu.d.ts +0 -6
  118. package/react/components/menus/content/partials/ContentFieldMenuItem.d.ts +0 -6
  119. package/react/components/menus/content/partials/ContentValueMenuItem.d.ts +0 -7
  120. package/react/components/menus/link/AnchorInput.d.ts +0 -8
  121. package/react/components/menus/link/LinkInput.d.ts +0 -11
  122. package/react/components/menus/link/LinkMenu.d.ts +0 -18
  123. package/react/components/menus/partials/MenuButton.d.ts +0 -7
  124. package/react/components/menus/partials/MenuContainer.d.ts +0 -4
  125. package/react/components/menus/partials/MenuHeader.d.ts +0 -5
  126. package/react/components/toolbar/Toolbar.d.ts +0 -6
  127. package/react/components/toolbar/ToolbarBlockButton.d.ts +0 -12
  128. package/react/components/toolbar/ToolbarHeadingDropdownButton.d.ts +0 -2
  129. package/react/components/toolbar/ToolbarLinkButton.d.ts +0 -6
  130. package/react/components/toolbar/ToolbarMarkButton.d.ts +0 -6
  131. package/react/components/toolbar/content/ContentExtractToolbarButton.d.ts +0 -2
  132. package/react/components/toolbar/content/ContentLibraryToolbarButton.d.ts +0 -5
  133. package/react/components/toolbar/content/ContentToolbar.d.ts +0 -4
  134. package/react/components/toolbar/link/ToolbarDisplayLink.d.ts +0 -2
  135. package/react/components/toolbar/link/UnlinkButton.d.ts +0 -2
  136. package/react/config/hotkeys.d.ts +0 -2
  137. package/react/plugins/index.d.ts +0 -3
  138. package/react/store/editorSlice.d.ts +0 -169
  139. package/react/store/store.d.ts +0 -5
  140. package/react/types.d.ts +0 -65
  141. package/react/utils/decorator.d.ts +0 -15
  142. package/react/utils/index.d.ts +0 -17
@@ -0,0 +1,195 @@
1
+ import { useLazyQuery } from "@apollo/client";
2
+ import {
3
+ AllContentExperiencesDocument,
4
+ ContentExperience,
5
+ SortingDirection,
6
+ } from "../../../../../__generated__/graphql";
7
+ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
8
+ import { debounce } from "lodash";
9
+ import { useClickOutside } from "../../../utils/index";
10
+ import { LinkData } from "./LinkMenu";
11
+ import { FontAwesomeIcon } from "../../FontAwesomeIcon";
12
+ import { useTranslation } from "react-i18next";
13
+
14
+ export function LinkInput({
15
+ onChange,
16
+ onContentExperienceChanged,
17
+ value,
18
+ placeholder,
19
+ label,
20
+ contentExperiencePrn,
21
+ gridPlacementPrn,
22
+ }: {
23
+ onChange: (partial: Partial<LinkData>) => void;
24
+ onContentExperienceChanged: (contentExperience?: ContentExperience) => void;
25
+ value: string;
26
+ placeholder?: string;
27
+ label: string;
28
+ contentExperiencePrn?: string;
29
+ gridPlacementPrn?: string;
30
+ }): React.ReactElement {
31
+ const perPage = 5;
32
+ const wrapperRef = useRef(null);
33
+ const [isExpanded, setIsExpanded] = useState(false);
34
+ const [searchValue, setSearchValue] = useState("");
35
+ const [isTyping, setIsTyping] = useState(false);
36
+ const inputRef = useRef<HTMLInputElement>(null);
37
+
38
+ const [loadContentExperiences, { data, loading: isLoading }] = useLazyQuery(AllContentExperiencesDocument, {
39
+ variables: {
40
+ paginate: {
41
+ first: perPage,
42
+ },
43
+ orderBy: {
44
+ key: "createdAt",
45
+ direction: SortingDirection.Desc,
46
+ },
47
+ where: {
48
+ title: [
49
+ {
50
+ _ilike: searchValue,
51
+ },
52
+ ],
53
+ },
54
+ },
55
+ notifyOnNetworkStatusChange: true,
56
+ });
57
+
58
+ const { t: translate } = useTranslation();
59
+
60
+ const selectedContentExperience = useMemo(
61
+ () => data?.contentExperiences.edges.find((edge) => (edge.node as ContentExperience).prn === contentExperiencePrn),
62
+ [data, contentExperiencePrn],
63
+ );
64
+
65
+ useClickOutside(wrapperRef, () => {
66
+ setIsExpanded(false);
67
+ });
68
+
69
+ useEffect(() => {
70
+ if (!isExpanded) {
71
+ return;
72
+ }
73
+
74
+ if (inputRef.current) {
75
+ inputRef.current.focus();
76
+ }
77
+
78
+ void loadContentExperiences();
79
+ }, [isExpanded]);
80
+
81
+ const debouncedUpdateValue = useCallback(
82
+ debounce((newValue: string) => {
83
+ setSearchValue(newValue);
84
+ setIsTyping(false);
85
+ }, 1000),
86
+ [],
87
+ );
88
+
89
+ function onInputChange(event: React.ChangeEvent<HTMLInputElement>): void {
90
+ if (event.target.value.length > 0) {
91
+ setIsExpanded(true);
92
+ }
93
+
94
+ setIsTyping(true);
95
+ onContentExperienceChanged(undefined);
96
+ onChange({ url: event.target.value });
97
+
98
+ debouncedUpdateValue(event.target.value);
99
+ }
100
+
101
+ function onContentExperienceClicked(clickedContentExperience: ContentExperience): void {
102
+ onContentExperienceChanged(clickedContentExperience);
103
+ setSearchValue("");
104
+ setIsExpanded(false);
105
+ }
106
+
107
+ return (
108
+ <div className={"flex flex-col gap-1 w-full"}>
109
+ <label className={"font-semibold text-xs"}>{label}</label>
110
+
111
+ <div ref={wrapperRef} className={"relative w-full"}>
112
+ <div
113
+ className={`flex items-center gap-4 border border-gray-300 rounded-md h-12 px-5`}
114
+ >
115
+ <div className="">
116
+ {contentExperiencePrn !== undefined || gridPlacementPrn !== undefined ? (
117
+ <FontAwesomeIcon icon={["fal", "sparkles"]} className={"text-primary"} />
118
+ ) : (
119
+ <FontAwesomeIcon icon={["fal", "globe"]} className={"text-primary"} />
120
+ )}
121
+ </div>
122
+ {isExpanded ? (
123
+ <input
124
+ ref={inputRef}
125
+ onChange={onInputChange}
126
+ placeholder={placeholder}
127
+ value={value}
128
+ className={`w-full outline-none`}
129
+ type="text"
130
+ data-testid="link-input"
131
+ />
132
+ ) : null}
133
+
134
+ {!isExpanded ? (
135
+ <div className="w-full truncate leading-none" onClick={() => setIsExpanded(!isExpanded)}>
136
+ {selectedContentExperience ? (
137
+ <p className="text-sm">{(selectedContentExperience?.node as ContentExperience)?.title}</p>
138
+ ) : null}
139
+ <p
140
+ data-testid="link-input-value"
141
+ className={`${selectedContentExperience ? "text-xs text-gray-500" : ""}`}
142
+ >
143
+ {value !== "" ? value : placeholder}
144
+ </p>
145
+ </div>
146
+ ) : null}
147
+
148
+ <div onClick={() => setIsExpanded(!isExpanded)} className="p-3 -mr-5 cursor-pointer">
149
+ <FontAwesomeIcon
150
+ icon={["fal", "chevron-down"]}
151
+ className={`text-gray-500 transition-all duration-300 ${isExpanded ? "rotate-180" : ""}`}
152
+ />
153
+ </div>
154
+ </div>
155
+
156
+ {isExpanded ? (
157
+ <div className={"absolute w-full bg-white shadow-md rounded-md border border-gray-300 mt-1 z-10"}>
158
+ {isLoading || isTyping
159
+ ? Array.from({ length: perPage }).map((_, index) => (
160
+ <div className={"px-5 py-3 flex items-center gap-2"} key={`loading-${index}`}>
161
+ <div className={"w-full h-5 bg-slate-100 rounded-sm animate-pulse"} />
162
+ </div>
163
+ ))
164
+ : null}
165
+
166
+ {data?.contentExperiences.edges.length === 0 && !isTyping ? (
167
+ <div className={"px-5 py-3"}>
168
+ <p>{translate("smart_text.toolbar.link.no_results_found")}</p>
169
+ </div>
170
+ ) : null}
171
+
172
+ {!isTyping &&
173
+ data?.contentExperiences.edges.map((edge) => (
174
+ <div
175
+ className={"px-5 py-2 hover:bg-gray-100/20 cursor-pointer flex justify-between items-center"}
176
+ key={(edge.node as ContentExperience).prn}
177
+ onClick={() => onContentExperienceClicked(edge.node as ContentExperience)}
178
+ >
179
+ <div>
180
+ <p>{(edge.node as ContentExperience).title}</p>
181
+ <p className={"text-sm text-gray-500"}>
182
+ {`${(edge.node as ContentExperience).pathPart.channel.domain}${(edge.node as ContentExperience).pathPart.path}`}
183
+ </p>
184
+ </div>
185
+ {contentExperiencePrn === (edge.node as ContentExperience).prn ? (
186
+ <FontAwesomeIcon icon={["fal", "check"]} className={"text-primary"} />
187
+ ) : null}
188
+ </div>
189
+ ))}
190
+ </div>
191
+ ) : null}
192
+ </div>
193
+ </div>
194
+ );
195
+ }
@@ -0,0 +1,145 @@
1
+ import { render, screen } from "@testing-library/react";
2
+ import { MockedProvider } from "@apollo/client/testing";
3
+ import { LinkMenu } from "./LinkMenu";
4
+ import { beforeEach, describe, it, vi } from "vitest";
5
+ import { Descendant, createEditor } from "slate";
6
+ import { Slate, withReact } from "slate-react";
7
+ import { LinkElement } from "../../../types";
8
+
9
+ // Mock only the findElement function
10
+ vi.mock("../../../utils", () => ({
11
+ findElement: vi.fn(),
12
+ }));
13
+
14
+ describe("LinkMenu", () => {
15
+ const mockOnClose = vi.fn();
16
+ const editor = withReact(createEditor());
17
+ const slateEditorValue: Descendant[] = [
18
+ {
19
+ type: "paragraph",
20
+ children: [{ text: "Test text" }],
21
+ },
22
+ ];
23
+
24
+ beforeEach(() => {
25
+ vi.clearAllMocks();
26
+ editor.selection = {
27
+ anchor: { path: [0, 0], offset: 0 },
28
+ focus: { path: [0, 0], offset: 4 },
29
+ };
30
+ });
31
+
32
+ it("should render", () => {
33
+ render(
34
+ <MockedProvider>
35
+ <Slate editor={editor} initialValue={slateEditorValue}>
36
+ <LinkMenu buttonText={"Add link"} onClose={mockOnClose} showMenu={true} />,
37
+ </Slate>
38
+ </MockedProvider>,
39
+ );
40
+ });
41
+
42
+ it("should show urlText if there is an active link", async () => {
43
+ // Import the mocked function
44
+ const { findElement, useClickOutside } = await import("../../../utils");
45
+ // eslint-disable-next-line @typescript-eslint/no-empty-function
46
+ vi.mocked(useClickOutside).mockImplementation(() => {});
47
+
48
+ // Mock the findElement to return a link element
49
+ const mockLinkElement: LinkElement = {
50
+ type: "link",
51
+ url: "https://example.com",
52
+ target: "blank",
53
+ children: [{ text: "Example Link Text" }],
54
+ };
55
+
56
+ vi.mocked(findElement).mockReturnValue([mockLinkElement, [0, 0]]);
57
+
58
+ // Create a real editor with selection
59
+ const initialValue: Descendant[] = [
60
+ {
61
+ type: "paragraph",
62
+ children: [{ text: "Test text" }],
63
+ },
64
+ ];
65
+
66
+ // Set up the editor selection
67
+ editor.selection = {
68
+ anchor: { path: [0, 0], offset: 0 },
69
+ focus: { path: [0, 0], offset: 4 },
70
+ };
71
+
72
+ // Render the component wrapped in Slate context
73
+ render(
74
+ <MockedProvider>
75
+ <Slate editor={editor} initialValue={initialValue}>
76
+ <LinkMenu buttonText={"Add link"} onClose={mockOnClose} showLinkTextInput={false} showMenu={true} />,
77
+ </Slate>
78
+ </MockedProvider>,
79
+ );
80
+
81
+ // Check that the URL input shows the correct URL
82
+ const urlInput: HTMLParagraphElement = screen.getByTestId("link-input-value");
83
+ expect(urlInput.textContent).toBe("https://example.com");
84
+ });
85
+
86
+ it("should not show urlText if there is no active link", async () => {
87
+ // Import the mocked function
88
+ const { findElement, useClickOutside } = await import("../../../utils");
89
+
90
+ // Mock the findElement to return undefined (no link)
91
+ vi.mocked(findElement).mockReturnValue(undefined);
92
+ // eslint-disable-next-line @typescript-eslint/no-empty-function
93
+ vi.mocked(useClickOutside).mockImplementation(() => {});
94
+
95
+ const initialValue: Descendant[] = [
96
+ {
97
+ type: "paragraph",
98
+ children: [{ text: "Test text" }],
99
+ },
100
+ ];
101
+
102
+ // Set up the editor selection
103
+ editor.selection = {
104
+ anchor: { path: [0, 0], offset: 0 },
105
+ focus: { path: [0, 0], offset: 4 },
106
+ };
107
+
108
+ // Render the component wrapped in Slate context
109
+ render(
110
+ <MockedProvider>
111
+ <Slate editor={editor} initialValue={initialValue}>
112
+ <LinkMenu buttonText={"Add link"} onClose={mockOnClose} showLinkTextInput={false} showMenu={true} />,
113
+ </Slate>
114
+ </MockedProvider>,
115
+ );
116
+
117
+ const textInputs: HTMLInputElement[] = screen.queryAllByTestId("text-input");
118
+
119
+ expect(textInputs.length).toBe(0);
120
+ });
121
+
122
+ it("should show link text input if showLinkTextInput is true", async () => {
123
+ // Import the mocked function
124
+ vi.mock("../../../utils", () => ({
125
+ findElement: vi.fn().mockReturnValue([{ type: "link", children: [{ text: "Test text" }] }, [0, 0]]),
126
+ hasElementSelected: vi.fn().mockReturnValue(true),
127
+ // eslint-disable-next-line @typescript-eslint/no-empty-function
128
+ useClickOutside: vi.fn().mockImplementation(() => {}),
129
+ }));
130
+
131
+ render(
132
+ <MockedProvider>
133
+ <Slate editor={editor} initialValue={slateEditorValue}>
134
+ <LinkMenu buttonText={"Add link"} onClose={mockOnClose} showLinkTextInput={true} showMenu={true} />,
135
+ </Slate>
136
+ </MockedProvider>,
137
+ );
138
+
139
+ const linkTextInputs: HTMLParagraphElement[] = screen.getAllByTestId("link-input-value");
140
+ const textInputs: HTMLInputElement[] = screen.getAllByTestId("text-input");
141
+
142
+ expect(linkTextInputs.length).toBe(1);
143
+ expect(textInputs.length).toBe(1);
144
+ });
145
+ });
@@ -0,0 +1,289 @@
1
+ import React, { useEffect, useState } from "react";
2
+ import { LinkInput } from "./LinkInput";
3
+ import { useSlate } from "slate-react";
4
+ import { Editor, Range, Transforms } from "slate";
5
+ import { URL_REGEX } from "@platecms/delta-cast";
6
+ import { findElement } from "../../../utils";
7
+ import { FontAwesomeIcon } from "../../FontAwesomeIcon";
8
+ import { LinkElement } from "../../../types";
9
+ import { TextInput } from "../../inputs/TextInput";
10
+ import { AnchorInput } from "./AnchorInput";
11
+ import { useTranslation } from "react-i18next";
12
+ import { capitalCase } from "change-case";
13
+ export interface LinkData {
14
+ text: string;
15
+ url: string;
16
+ target: LinkElement["target"];
17
+ internal?: {
18
+ prn: string;
19
+ anchor?: boolean;
20
+ };
21
+ }
22
+
23
+ export function LinkMenu({
24
+ showLinkTextInput = false,
25
+ buttonText,
26
+ menuClass,
27
+ showMenu,
28
+ onClose,
29
+ }: {
30
+ showLinkTextInput?: boolean;
31
+ buttonText: string;
32
+ menuClass?: string;
33
+ showMenu?: boolean;
34
+ onClose: () => void;
35
+ }): React.ReactElement | null {
36
+ const editor = useSlate();
37
+
38
+ const { selection } = editor;
39
+
40
+ const [start, end] = Range.edges(
41
+ selection ?? { anchor: { path: [0, 0], offset: 0 }, focus: { path: [0, 0], offset: 0 } },
42
+ );
43
+
44
+ const [link, setLink] = useState<LinkData>({
45
+ text: Editor.string(editor, Editor.range(editor, start, end)),
46
+ url: "",
47
+ target: "self",
48
+ });
49
+
50
+ const [contentExperiencePrn, setContentExperiencePrn] = useState<string | undefined>(undefined);
51
+ const [gridPlacementPrn, setGridPlacementPrn] = useState<string | undefined>(undefined);
52
+
53
+ useEffect(() => {
54
+ function handleKeyDown(event: KeyboardEvent): void {
55
+ if (event.key === "Escape") {
56
+ onClose?.();
57
+ }
58
+ }
59
+
60
+ // Attach listener when component mounts
61
+ document.addEventListener("keydown", handleKeyDown);
62
+
63
+ // Clean up when component unmounts
64
+ return (): void => {
65
+ document.removeEventListener("keydown", handleKeyDown);
66
+ };
67
+ }, [onClose]);
68
+
69
+ useEffect(() => {
70
+ if (!hasActiveLink()) {
71
+ return;
72
+ }
73
+
74
+ initialize();
75
+ }, [showMenu]);
76
+
77
+ function partialUpdate(updated: Partial<LinkData>): void {
78
+ setLink({
79
+ text: updated.text ?? link.text,
80
+ url: updated.url ?? link.url,
81
+ target: updated.target ?? link.target,
82
+ // If the internal is undefined, we want to remove it
83
+ internal: updated.internal === undefined ? undefined : (updated.internal ?? link.internal),
84
+ });
85
+ }
86
+
87
+ function hasActiveLink(): boolean {
88
+ return findElement(editor, "link") !== undefined;
89
+ }
90
+
91
+ function insertOrUpdateLink(): void {
92
+ if (hasActiveLink()) {
93
+ updateLink();
94
+ } else {
95
+ insertLink();
96
+ }
97
+
98
+ reset();
99
+ onClose();
100
+ }
101
+
102
+ function insertLink(): void {
103
+ Transforms.insertNodes(
104
+ editor,
105
+ {
106
+ type: "link",
107
+ url: link.url,
108
+ target: link.target,
109
+ children: [{ text: link.text }],
110
+ internal: link.internal,
111
+ },
112
+ {
113
+ at: { anchor: start, focus: end },
114
+ },
115
+ );
116
+ }
117
+
118
+ const { t: translate } = useTranslation();
119
+
120
+ function updateLink(): void {
121
+ const linkEntry = findElement(editor, "link");
122
+ if (!linkEntry) {
123
+ return;
124
+ }
125
+
126
+ const [, path] = linkEntry;
127
+ Transforms.setNodes(
128
+ editor,
129
+ {
130
+ url: link.url,
131
+ target: link.target,
132
+ internal: link.internal,
133
+ },
134
+ { at: path },
135
+ );
136
+ }
137
+
138
+ function reset(): void {
139
+ setLink({
140
+ text: "",
141
+ url: "",
142
+ target: "self",
143
+ });
144
+ }
145
+
146
+ function initialize(): void {
147
+ const linkEntry = findElement(editor, "link");
148
+
149
+ if (!linkEntry) {
150
+ return;
151
+ }
152
+
153
+ const [linkElement] = linkEntry as [LinkElement, number[]];
154
+
155
+ setLink({
156
+ text: linkElement.children[0].text,
157
+ url: linkElement.url,
158
+ target: linkElement.target,
159
+ internal: linkElement.internal,
160
+ });
161
+
162
+ if (linkElement.internal?.anchor) {
163
+ setGridPlacementPrn(linkElement.internal.prn);
164
+ } else if (linkElement.internal?.prn) {
165
+ setContentExperiencePrn(linkElement.internal.prn);
166
+ }
167
+ }
168
+
169
+ const isValidUrl = URL_REGEX.test(link.url);
170
+
171
+ if (!selection) {
172
+ return null;
173
+ }
174
+
175
+ return (
176
+ <div className={`absolute left-0 mt-1 z-10 min-w-sm w-[20vw] ${menuClass ?? ""}`}>
177
+ {showMenu ? (
178
+ <div className={"bg-white py-3 shadow-2xl outline-1 outline-gray-100 rounded-md"}>
179
+ <div className="px-4 flex flex-col gap-4">
180
+ <LinkInput
181
+ onChange={partialUpdate}
182
+ placeholder={translate("smart_text.toolbar.link.enter_link")}
183
+ label={translate("smart_text.toolbar.link.title")}
184
+ value={link.url}
185
+ contentExperiencePrn={contentExperiencePrn}
186
+ gridPlacementPrn={gridPlacementPrn}
187
+ onContentExperienceChanged={(contentExperience) => {
188
+ if (contentExperience) {
189
+ setContentExperiencePrn(contentExperience.prn as string);
190
+ setGridPlacementPrn(undefined);
191
+ partialUpdate({
192
+ url: `${contentExperience.pathPart.channel.domain}${contentExperience.pathPart.path}`,
193
+ text: link.text,
194
+ internal: {
195
+ prn: contentExperience.prn,
196
+ anchor: false,
197
+ },
198
+ });
199
+ } else {
200
+ setContentExperiencePrn(undefined);
201
+ setGridPlacementPrn(undefined);
202
+ partialUpdate({
203
+ internal: undefined,
204
+ });
205
+ }
206
+ }}
207
+ />
208
+ {contentExperiencePrn ? (
209
+ <AnchorInput
210
+ label={translate("smart_text.toolbar.link.select_section")}
211
+ value={link.url}
212
+ contentExperiencePrn={contentExperiencePrn}
213
+ gridPlacementPrn={gridPlacementPrn}
214
+ onGridPlacementChanged={(gridPlacement) => {
215
+ if (gridPlacement) {
216
+ setGridPlacementPrn(gridPlacement.prn as string);
217
+ partialUpdate({
218
+ url: `${link.url.split("#")[0]}#${gridPlacement.row}`,
219
+ internal: { prn: gridPlacement.prn, anchor: true },
220
+ });
221
+ } else {
222
+ setGridPlacementPrn(undefined);
223
+ partialUpdate({
224
+ url: link.url.split("#")[0],
225
+ internal: { prn: contentExperiencePrn, anchor: false },
226
+ });
227
+ }
228
+ }}
229
+ />
230
+ ) : null}
231
+ {showLinkTextInput ? (
232
+ <TextInput
233
+ onChange={(text) => partialUpdate({ text })}
234
+ placeholder={translate("smart_text.toolbar.link.enter_display_text")}
235
+ value={link.text}
236
+ label={translate("smart_text.toolbar.link.display_text")}
237
+ />
238
+ ) : null}
239
+
240
+ <div className={"flex items-center gap-2"}>
241
+ <div
242
+ className="flex items-center gap-2 cursor-pointer pt-1 pb-3"
243
+ onClick={() => setLink({ ...link, target: link.target === "blank" ? "self" : "blank" })}
244
+ >
245
+ <div className="relative w-5 h-5">
246
+ <input
247
+ type="checkbox"
248
+ name="target"
249
+ id="target"
250
+ className="w-5 h-5 opacity-0 z-10 absolute cursor-pointer pointer-events-none"
251
+ checked={link.target === "blank"}
252
+ onChange={() => setLink({ ...link, target: link.target === "blank" ? "self" : "blank" })}
253
+ />
254
+ <div
255
+ className={`h-5 w-5 rounded-md absolute z-0 top-0 left-0 flex items-center justify-center border border-gray-200 ${
256
+ link.target === "blank" ? "bg-primary" : "bg-white"
257
+ }`}
258
+ >
259
+ {link.target === "blank" ? (
260
+ <FontAwesomeIcon icon={["fal", "check"]} className="text-white" size="sm" />
261
+ ) : null}
262
+ </div>
263
+ </div>
264
+ <label className="cursor-pointer pointer-events-none -mb-1" htmlFor="target">
265
+ {translate("smart_text.toolbar.link.new_tab")}
266
+ </label>
267
+ </div>
268
+ </div>
269
+ </div>
270
+ <div className={"flex justify-end gap-2 border-t border-gray-100 px-4 pt-4"}>
271
+ <button
272
+ onClick={onClose}
273
+ className={"border border-gray-300 text-gray-500 px-4 pt-2 pb-1.5 rounded-md cursor-pointer hover:shadow-md transition-all duration-300"}
274
+ >
275
+ {capitalCase(translate("general.word.cancel"))}
276
+ </button>
277
+ <button
278
+ onClick={insertOrUpdateLink}
279
+ disabled={!isValidUrl}
280
+ className={`bg-primary text-white px-4 pt-2 pb-1.5 rounded-md cursor-pointer hover:shadow-md transition-all duration-300 ${!isValidUrl ? "opacity-50 cursor-not-allowed" : ""}`}
281
+ >
282
+ {buttonText}
283
+ </button>
284
+ </div>
285
+ </div>
286
+ ) : null}
287
+ </div>
288
+ );
289
+ }
@@ -0,0 +1,52 @@
1
+ import React, { ReactNode, useRef, useState } from "react";
2
+ import { useClickOutside } from "../../../utils";
3
+ import { FontAwesomeIcon } from "../../FontAwesomeIcon";
4
+
5
+ export function MenuButton({
6
+ icon,
7
+ text,
8
+ onClick,
9
+ children,
10
+ }: {
11
+ icon?: [string, string];
12
+ text: string;
13
+ onClick?: (event: React.MouseEvent) => void;
14
+ children?: ReactNode;
15
+ }): React.ReactElement {
16
+ const [isExpanded, setIsExpanded] = useState(false);
17
+
18
+ const wrapperRef = useRef(null);
19
+ useClickOutside(wrapperRef, () => {
20
+ setIsExpanded(false);
21
+ });
22
+
23
+ function handleOnClick(event: React.MouseEvent): void {
24
+ if (children) {
25
+ setIsExpanded(!isExpanded);
26
+ }
27
+
28
+ if (onClick) {
29
+ onClick(event);
30
+ }
31
+
32
+ event.preventDefault();
33
+ }
34
+
35
+ return (
36
+ <div ref={wrapperRef}>
37
+ <div
38
+ onClick={(event) => handleOnClick(event)}
39
+ className={`px-3 py-2 flex items-center justify-between gap-2 cursor-pointer hover:bg-gray-50 transition-colors rounded-md ${isExpanded ? "bg-primary-light" : ""}`}
40
+ >
41
+ <div className={"flex items-center gap-2"}>
42
+ {icon ? <FontAwesomeIcon icon={icon} className={"text-[#705ED9]"} /> : null}
43
+ <p className={"w-40 text-base"}>{text}</p>
44
+ </div>
45
+
46
+ {children ? <FontAwesomeIcon icon={["fal", "chevron-right"]} className={"text-gray-400"} size={"xs"} /> : null}
47
+ </div>
48
+
49
+ {isExpanded ? <div className={"absolute top-0 left-full ml-1"}>{children}</div> : null}
50
+ </div>
51
+ );
52
+ }
@@ -0,0 +1,9 @@
1
+ import React, { ReactNode } from "react";
2
+
3
+ export function MenuContainer({ children }: { children?: ReactNode }): React.ReactElement {
4
+ return (
5
+ <div className={"bg-white px-4 py-2 shadow-2xl outline outline-1 outline-gray-100 rounded-md space-y-2"}>
6
+ {children}
7
+ </div>
8
+ );
9
+ }
@@ -0,0 +1,11 @@
1
+ import React from "react";
2
+ import { FontAwesomeIcon } from "../../FontAwesomeIcon";
3
+
4
+ export function MenuHeader({ text, icon }: { text: string; icon?: [string, string] }): React.ReactElement {
5
+ return (
6
+ <div className={"px-2 py-2 whitespace-nowrap flex items-center gap-2"}>
7
+ {icon ? <FontAwesomeIcon icon={icon} size={"xs"} /> : null}
8
+ <p className={"font-semibold uppercase text-xs"}>{text}</p>
9
+ </div>
10
+ );
11
+ }