@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.
- package/.env.example +7 -0
- package/__generated__/fragment-masking.ts +87 -0
- package/__generated__/gql.ts +238 -0
- package/__generated__/graphql.ts +3441 -0
- package/__generated__/index.ts +2 -0
- package/codegen.config.ts +24 -0
- package/eslint.config.mjs +43 -0
- package/i18n.js +28 -0
- package/package.json +5 -5
- package/project.json +54 -0
- package/src/components/DeltaSlateEditor.vue +74 -0
- package/src/components/icon/FontAwesomeIcon.vue +21 -0
- package/src/graphql/apiTokens/apiTokens.fragments.gql +8 -0
- package/src/graphql/assets/assets.fragments.gql +10 -0
- package/src/graphql/blueprints/blueprints.fragments.gql +52 -0
- package/src/graphql/buildingBlockFieldFulfillments/buildingBlockFieldFullfillment.fragements.gql +6 -0
- package/src/graphql/buildingBlockFields/buildingBlockField.fragments.gql +8 -0
- package/src/graphql/buildingBlocks/buildingBlocks.fragments.gql +11 -0
- package/src/graphql/channels/channels.fragments.gql +9 -0
- package/src/graphql/contentExperiences/allContentExperiences.query.gql +24 -0
- package/src/graphql/contentExperiences/contentExperience.query.gql +20 -0
- package/src/graphql/contentExperiences/contentExperiences.fragments.gql +14 -0
- package/src/graphql/contentFields/contentFields.fragments.gql +7 -0
- package/src/graphql/contentItems/allContentItems.query.gql +48 -0
- package/src/graphql/contentItems/contentItems.fragments.gql +11 -0
- package/src/graphql/contentTypes/allContentTypes.query.gql +26 -0
- package/src/graphql/contentTypes/contentTypes.fragments.gql +11 -0
- package/src/graphql/contentValidations/contentValidationRule.fragments.gql +34 -0
- package/src/graphql/contentValues/allContentValues.query.gql +41 -0
- package/src/graphql/contentValues/contentValues.fragments.gql +9 -0
- package/src/graphql/contentValues/createContentValue.mutation.gql +17 -0
- package/src/graphql/experienceComponents/experienceComponent.fragments.gql +13 -0
- package/src/graphql/fragments.gql +6 -0
- package/src/graphql/gridDefinition/gridDefinition.fragments.gql +5 -0
- package/src/graphql/gridPlacements/gridPlacement.fragments.gql +7 -0
- package/src/graphql/grids/grid.fragments.gql +7 -0
- package/src/graphql/invitations/invitations.fragments.gql +7 -0
- package/src/graphql/organizations/organizations.fragments.gql +13 -0
- package/src/graphql/pathParts/pathParts.fragments.gql +19 -0
- package/src/graphql/plateMaintainers/plateMaintainer.fragements.gql +10 -0
- package/src/graphql/roleAssignments/roleAssignment.fragments.gql +9 -0
- package/src/graphql/roles/roles.fragments.gql +7 -0
- package/src/graphql/subject/subject.fragments.gql +8 -0
- package/src/graphql/tags/tags.fragments.gql +17 -0
- package/src/graphql/themes/themes.fragments.gql +8 -0
- package/src/index.css +1 -0
- package/src/index.ts +21 -0
- package/src/locales/en.json +52 -0
- package/src/locales/nl.json +52 -0
- package/src/react/components/DeltaSlateEditor.tsx +243 -0
- package/src/react/components/DeltaSlateEditorConnector.tsx +50 -0
- package/src/react/components/Element.spec.tsx +244 -0
- package/src/react/components/Element.tsx +151 -0
- package/src/react/components/FontAwesomeIcon.tsx +17 -0
- package/src/react/components/Leaf.spec.tsx +61 -0
- package/src/react/components/Leaf.tsx +22 -0
- package/src/react/components/elements/CodeElement.tsx +16 -0
- package/src/react/components/elements/ContentValueElement.tsx +33 -0
- package/src/react/components/elements/LinkElement.tsx +44 -0
- package/src/react/components/inputs/SearchInput.tsx +22 -0
- package/src/react/components/inputs/TextInput.tsx +30 -0
- package/src/react/components/menus/ContentAndFormatMenu.tsx +272 -0
- package/src/react/components/menus/ContentLibraryMenu.tsx +48 -0
- package/src/react/components/menus/ReusableContentMenu.tsx +190 -0
- package/src/react/components/menus/content/ContentItemsMenu.tsx +215 -0
- package/src/react/components/menus/content/ContentTypesMenu.tsx +129 -0
- package/src/react/components/menus/content/partials/ContentFieldMenuItem.tsx +11 -0
- package/src/react/components/menus/content/partials/ContentValueMenuItem.tsx +58 -0
- package/src/react/components/menus/link/AnchorInput.tsx +123 -0
- package/src/react/components/menus/link/LinkInput.tsx +195 -0
- package/src/react/components/menus/link/LinkMenu.spec.tsx +145 -0
- package/src/react/components/menus/link/LinkMenu.tsx +289 -0
- package/src/react/components/menus/partials/MenuButton.tsx +52 -0
- package/src/react/components/menus/partials/MenuContainer.tsx +9 -0
- package/src/react/components/menus/partials/MenuHeader.tsx +11 -0
- package/src/react/components/toolbar/Toolbar.tsx +249 -0
- package/src/react/components/toolbar/ToolbarBlockButton.tsx +31 -0
- package/src/react/components/toolbar/ToolbarHeadingDropdownButton.tsx +76 -0
- package/src/react/components/toolbar/ToolbarLinkButton.tsx +33 -0
- package/src/react/components/toolbar/ToolbarMarkButton.tsx +25 -0
- package/src/react/components/toolbar/content/ContentExtractToolbarButton.tsx +68 -0
- package/src/react/components/toolbar/content/ContentLibraryToolbarButton.tsx +43 -0
- package/src/react/components/toolbar/content/ContentToolbar.tsx +37 -0
- package/src/react/components/toolbar/link/ToolbarDisplayLink.tsx +36 -0
- package/src/react/components/toolbar/link/UnlinkButton.tsx +25 -0
- package/src/react/config/hotkeys.ts +8 -0
- package/src/react/plugins/index.ts +59 -0
- package/src/react/store/editorSlice.ts +124 -0
- package/src/react/store/store.ts +12 -0
- package/src/react/types.ts +87 -0
- package/src/react/utils/decorator.ts +61 -0
- package/src/react/utils/index.ts +110 -0
- package/src/vue-shims.d.ts +5 -0
- package/tsconfig.json +26 -0
- package/tsconfig.lib.json +25 -0
- package/tsconfig.spec.json +22 -0
- package/vite.config.ts +67 -0
- package/components/DeltaSlateEditor.vue.d.ts +0 -26
- package/index.cjs +0 -381
- package/index.css +0 -1
- package/index.d.ts +0 -12
- package/index.js +0 -49254
- package/react/components/DeltaSlateEditor.d.ts +0 -7
- package/react/components/DeltaSlateEditorConnector.d.ts +0 -12
- package/react/components/Element.d.ts +0 -8
- package/react/components/FontAwesomeIcon.d.ts +0 -6
- package/react/components/Leaf.d.ts +0 -3
- package/react/components/elements/CodeElement.d.ts +0 -8
- package/react/components/elements/ContentValueElement.d.ts +0 -8
- package/react/components/elements/LinkElement.d.ts +0 -8
- package/react/components/inputs/SearchInput.d.ts +0 -5
- package/react/components/inputs/TextInput.d.ts +0 -7
- package/react/components/menus/ContentAndFormatMenu.d.ts +0 -10
- package/react/components/menus/ContentLibraryMenu.d.ts +0 -4
- package/react/components/menus/ReusableContentMenu.d.ts +0 -3
- package/react/components/menus/content/ContentItemsMenu.d.ts +0 -5
- package/react/components/menus/content/ContentTypesMenu.d.ts +0 -6
- package/react/components/menus/content/partials/ContentFieldMenuItem.d.ts +0 -6
- package/react/components/menus/content/partials/ContentValueMenuItem.d.ts +0 -7
- package/react/components/menus/link/AnchorInput.d.ts +0 -8
- package/react/components/menus/link/LinkInput.d.ts +0 -11
- package/react/components/menus/link/LinkMenu.d.ts +0 -18
- package/react/components/menus/partials/MenuButton.d.ts +0 -7
- package/react/components/menus/partials/MenuContainer.d.ts +0 -4
- package/react/components/menus/partials/MenuHeader.d.ts +0 -5
- package/react/components/toolbar/Toolbar.d.ts +0 -6
- package/react/components/toolbar/ToolbarBlockButton.d.ts +0 -12
- package/react/components/toolbar/ToolbarHeadingDropdownButton.d.ts +0 -2
- package/react/components/toolbar/ToolbarLinkButton.d.ts +0 -6
- package/react/components/toolbar/ToolbarMarkButton.d.ts +0 -6
- package/react/components/toolbar/content/ContentExtractToolbarButton.d.ts +0 -2
- package/react/components/toolbar/content/ContentLibraryToolbarButton.d.ts +0 -5
- package/react/components/toolbar/content/ContentToolbar.d.ts +0 -4
- package/react/components/toolbar/link/ToolbarDisplayLink.d.ts +0 -2
- package/react/components/toolbar/link/UnlinkButton.d.ts +0 -2
- package/react/config/hotkeys.d.ts +0 -2
- package/react/plugins/index.d.ts +0 -3
- package/react/store/editorSlice.d.ts +0 -169
- package/react/store/store.d.ts +0 -5
- package/react/types.d.ts +0 -65
- package/react/utils/decorator.d.ts +0 -15
- 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
|
+
}
|