@riducms/plugin-richtext 0.1.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/LICENSE +21 -0
- package/README.md +26 -0
- package/package.json +52 -0
- package/src/field/rich-text-config.ts +47 -0
- package/src/field/rich-text-context.svelte.ts +8 -0
- package/src/field/rich-text-document.ts +20 -0
- package/src/field/rich-text-field.svelte +187 -0
- package/src/field/rich-text-footer.svelte +49 -0
- package/src/index.ts +33 -0
- package/src/menu/rich-text-block-toolbar.svelte +323 -0
- package/src/menu/rich-text-commands.ts +32 -0
- package/src/menu/rich-text-menu.svelte +107 -0
- package/src/menu/rich-text-options.ts +244 -0
- package/src/menu/rich-text-slash-menu.svelte +124 -0
- package/src/messages.ts +240 -0
- package/src/relationship/rich-text-relationship-node.svelte +141 -0
- package/src/relationship/rich-text-relationship-node.ts +105 -0
- package/src/relationship/rich-text-relationship-plugin.svelte +174 -0
- package/src/styles/integration.css +83 -0
- package/src/toolbar/rich-text-floating-toolbar.svelte +322 -0
- package/src/toolbar/toolbar-icon.svelte +47 -0
- package/src/upload/rich-text-upload-node.svelte +282 -0
- package/src/upload/rich-text-upload-node.ts +144 -0
- package/src/upload/rich-text-upload-plugin.svelte +183 -0
- package/src/vite-env.d.ts +1 -0
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { getAdminI18n, type FieldAuthoringHost } from "@riducms/plugin";
|
|
3
|
+
import { PopoverContent, PopoverRoot } from "@riducms/ui";
|
|
4
|
+
import {
|
|
5
|
+
TypeaheadMenuPlugin,
|
|
6
|
+
createBasicTypeaheadTriggerMatch,
|
|
7
|
+
useLexicalComposerContext,
|
|
8
|
+
type MenuResolution,
|
|
9
|
+
} from "@hvniel/lexical-svelte";
|
|
10
|
+
import { $isParagraphNode, type TextNode } from "lexical";
|
|
11
|
+
|
|
12
|
+
import type { RichTextConfig } from "@plugin-richtext/field/rich-text-config";
|
|
13
|
+
import RichTextMenu from "@plugin-richtext/menu/rich-text-menu.svelte";
|
|
14
|
+
import {
|
|
15
|
+
buildRichTextOptions,
|
|
16
|
+
filterRichTextOptions,
|
|
17
|
+
type RichTextMenuOption,
|
|
18
|
+
} from "@plugin-richtext/menu/rich-text-options";
|
|
19
|
+
|
|
20
|
+
let { authoring, config }: { authoring: FieldAuthoringHost | undefined; config: RichTextConfig } =
|
|
21
|
+
$props();
|
|
22
|
+
const i18n = getAdminI18n();
|
|
23
|
+
const editor = useLexicalComposerContext()[0];
|
|
24
|
+
const trigger = createBasicTypeaheadTriggerMatch("/", { allowWhitespace: true, minLength: 0 });
|
|
25
|
+
// Menu options carry attachment identity and the mounted field's feature set is fixed.
|
|
26
|
+
// svelte-ignore state_referenced_locally
|
|
27
|
+
const baseOptions = buildRichTextOptions(editor, config, i18n, authoring);
|
|
28
|
+
let query = $state<string | null>(null);
|
|
29
|
+
let menuAnchor = $state.raw<{
|
|
30
|
+
contextElement: HTMLElement;
|
|
31
|
+
getBoundingClientRect: () => DOMRect;
|
|
32
|
+
} | null>(null);
|
|
33
|
+
let menuHost = $state<HTMLElement | null>(null);
|
|
34
|
+
const options = $derived(filterRichTextOptions(baseOptions, query ?? "", i18n.language));
|
|
35
|
+
|
|
36
|
+
$effect(() => {
|
|
37
|
+
if (query !== null) {
|
|
38
|
+
menuHost?.parentElement?.setAttribute(
|
|
39
|
+
"aria-label",
|
|
40
|
+
i18n.t("plugin.richtext:editor.insertBlock")
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
function openMenu(resolution: MenuResolution) {
|
|
46
|
+
const contextElement = editor.getRootElement();
|
|
47
|
+
if (contextElement === null) return;
|
|
48
|
+
menuAnchor = {
|
|
49
|
+
contextElement,
|
|
50
|
+
getBoundingClientRect: resolution.getRect,
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function closeMenu() {
|
|
55
|
+
menuAnchor = null;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function preserveEditorFocus(event: Event) {
|
|
59
|
+
event.preventDefault();
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function selectOption(
|
|
63
|
+
option: RichTextMenuOption,
|
|
64
|
+
nodeToRemove: TextNode | null,
|
|
65
|
+
closeMenu: () => void
|
|
66
|
+
) {
|
|
67
|
+
const placeholder = nodeToRemove?.getParent();
|
|
68
|
+
nodeToRemove?.remove();
|
|
69
|
+
option.select();
|
|
70
|
+
if (
|
|
71
|
+
!option.preservePlaceholder &&
|
|
72
|
+
$isParagraphNode(placeholder) &&
|
|
73
|
+
placeholder.getTextContentSize() === 0
|
|
74
|
+
) {
|
|
75
|
+
placeholder.remove();
|
|
76
|
+
}
|
|
77
|
+
closeMenu();
|
|
78
|
+
}
|
|
79
|
+
</script>
|
|
80
|
+
|
|
81
|
+
<TypeaheadMenuPlugin
|
|
82
|
+
onQueryChange={(value) => (query = value)}
|
|
83
|
+
onSelectOption={selectOption}
|
|
84
|
+
onOpen={openMenu}
|
|
85
|
+
onClose={closeMenu}
|
|
86
|
+
{options}
|
|
87
|
+
{trigger}
|
|
88
|
+
>
|
|
89
|
+
{#snippet menu(menuProps)}
|
|
90
|
+
<div bind:this={menuHost} role="presentation"></div>
|
|
91
|
+
{#if menuProps.options.length > 0 && menuAnchor !== null && menuHost !== null}
|
|
92
|
+
<PopoverRoot open>
|
|
93
|
+
<PopoverContent
|
|
94
|
+
portalTo={menuHost}
|
|
95
|
+
class="w-[min(19rem,calc(100vw-2rem))] p-0 animate-[ridu-richtext-menu-in_150ms_ease] motion-reduce:animate-none"
|
|
96
|
+
role="presentation"
|
|
97
|
+
customAnchor={menuAnchor}
|
|
98
|
+
side="bottom"
|
|
99
|
+
align="start"
|
|
100
|
+
sideOffset={3}
|
|
101
|
+
collisionPadding={8}
|
|
102
|
+
strategy="fixed"
|
|
103
|
+
trapFocus={false}
|
|
104
|
+
onOpenAutoFocus={preserveEditorFocus}
|
|
105
|
+
onCloseAutoFocus={preserveEditorFocus}
|
|
106
|
+
>
|
|
107
|
+
<RichTextMenu
|
|
108
|
+
options={menuProps.options}
|
|
109
|
+
selectedIndex={menuProps.selectedIndex}
|
|
110
|
+
onHighlight={(index) => {
|
|
111
|
+
menuProps.setHighlightedIndex(index);
|
|
112
|
+
editor
|
|
113
|
+
.getRootElement()
|
|
114
|
+
?.setAttribute("aria-activedescendant", `typeahead-item-${index}`);
|
|
115
|
+
}}
|
|
116
|
+
onSelect={menuProps.selectOption}
|
|
117
|
+
embedded
|
|
118
|
+
idPrefix="typeahead-item"
|
|
119
|
+
/>
|
|
120
|
+
</PopoverContent>
|
|
121
|
+
</PopoverRoot>
|
|
122
|
+
{/if}
|
|
123
|
+
{/snippet}
|
|
124
|
+
</TypeaheadMenuPlugin>
|
package/src/messages.ts
ADDED
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
import { defineAdminMessages } from "@riducms/plugin";
|
|
2
|
+
|
|
3
|
+
export const richTextMessages = defineAdminMessages({
|
|
4
|
+
fallback: {
|
|
5
|
+
"asset.actions": "Asset actions",
|
|
6
|
+
"asset.addCaption": "Add a caption…",
|
|
7
|
+
"asset.captionFor": "Caption for {filename}",
|
|
8
|
+
"asset.couldNotLoad": "The referenced asset could not be loaded",
|
|
9
|
+
"asset.file": "FILE",
|
|
10
|
+
"asset.label": "{collection}: {filename}",
|
|
11
|
+
"asset.remove": "Remove asset",
|
|
12
|
+
"asset.replace": "Replace asset",
|
|
13
|
+
"asset.unavailable": "Unavailable",
|
|
14
|
+
"editor.addBlock": "Add block",
|
|
15
|
+
"editor.addLink": "Add link",
|
|
16
|
+
"editor.apply": "Apply",
|
|
17
|
+
"editor.blockActions": "Block actions",
|
|
18
|
+
"editor.blockAlreadyFirst": "This block is already first.",
|
|
19
|
+
"editor.blockAlreadyLast": "This block is already last.",
|
|
20
|
+
"editor.blockMovedPosition": "Moved block to position {position} of {total}.",
|
|
21
|
+
"editor.bold": "Bold",
|
|
22
|
+
"editor.cancel": "Cancel",
|
|
23
|
+
"editor.dragToMoveBlock": "Drag to move block",
|
|
24
|
+
"editor.editLink": "Edit link",
|
|
25
|
+
"editor.editNamed": "Edit {name}",
|
|
26
|
+
"editor.empty": "No content",
|
|
27
|
+
"editor.filterBlocks": "Filter blocks",
|
|
28
|
+
"editor.filterBlocksPlaceholder": "Filter blocks…",
|
|
29
|
+
"editor.footerHint": "— type / for commands · move with Alt+Shift+↑/↓",
|
|
30
|
+
"editor.formatText": "Text formatting",
|
|
31
|
+
"editor.inlineCode": "Inline code",
|
|
32
|
+
"editor.insertBlock": "Insert block",
|
|
33
|
+
"editor.italic": "Italic",
|
|
34
|
+
"editor.linkURL": "Link URL",
|
|
35
|
+
"editor.noMatchingBlocks": "No matching blocks",
|
|
36
|
+
"editor.placeholder": "Write body copy, or type / for commands…",
|
|
37
|
+
"editor.remove": "Remove",
|
|
38
|
+
"editor.removeNamed": "Remove {name}",
|
|
39
|
+
"editor.replaceNamed": "Replace {name}",
|
|
40
|
+
"editor.strikethrough": "Strikethrough",
|
|
41
|
+
"editor.subscript": "Subscript",
|
|
42
|
+
"editor.superscript": "Superscript",
|
|
43
|
+
"editor.underline": "Underline",
|
|
44
|
+
"option.bulletedList.description": "Unordered list",
|
|
45
|
+
"option.bulletedList.keywords": "bulleted unordered list ul",
|
|
46
|
+
"option.bulletedList.label": "Bulleted list",
|
|
47
|
+
"option.checklist.description": "Tasks with checkboxes",
|
|
48
|
+
"option.checklist.keywords": "checklist todo task checkbox",
|
|
49
|
+
"option.checklist.label": "Checklist",
|
|
50
|
+
"option.codeBlock.description": "Preformatted code",
|
|
51
|
+
"option.codeBlock.keywords": "code block codeblock preformatted pre",
|
|
52
|
+
"option.codeBlock.label": "Code block",
|
|
53
|
+
"option.divider.description": "Horizontal rule",
|
|
54
|
+
"option.divider.keywords": "divider horizontal rule hr",
|
|
55
|
+
"option.divider.label": "Divider",
|
|
56
|
+
"option.heading2.description": "Section heading",
|
|
57
|
+
"option.heading2.keywords": "heading title section h2",
|
|
58
|
+
"option.heading2.label": "Heading 2",
|
|
59
|
+
"option.heading3.description": "Subsection heading",
|
|
60
|
+
"option.heading3.keywords": "heading subtitle subsection h3",
|
|
61
|
+
"option.heading3.label": "Heading 3",
|
|
62
|
+
"option.numberedList.description": "Ordered list",
|
|
63
|
+
"option.numberedList.keywords": "numbered ordered list ol",
|
|
64
|
+
"option.numberedList.label": "Numbered list",
|
|
65
|
+
"option.paragraph.description": "Plain body copy",
|
|
66
|
+
"option.paragraph.keywords": "paragraph text normal p",
|
|
67
|
+
"option.paragraph.label": "Paragraph",
|
|
68
|
+
"option.quote.description": "Quoted passage",
|
|
69
|
+
"option.quote.keywords": "quote blockquote citation",
|
|
70
|
+
"option.quote.label": "Quote",
|
|
71
|
+
"option.relationship.description": "Reference an existing {label}",
|
|
72
|
+
"option.relationship.keywords": "relationship reference document related",
|
|
73
|
+
"option.relationship.label": "Related {label}",
|
|
74
|
+
"option.upload.description": "Select or upload {label}",
|
|
75
|
+
"option.upload.keywords": "upload image photo media file asset",
|
|
76
|
+
"relationship.actions": "Relationship actions",
|
|
77
|
+
"relationship.label": "{collection}: {title}",
|
|
78
|
+
"relationship.remove": "Remove",
|
|
79
|
+
"relationship.replace": "Replace",
|
|
80
|
+
"relationship.unavailable": "Unavailable reference",
|
|
81
|
+
},
|
|
82
|
+
translations: {
|
|
83
|
+
fr: {
|
|
84
|
+
"asset.actions": "Actions sur le fichier",
|
|
85
|
+
"asset.addCaption": "Ajouter une légende…",
|
|
86
|
+
"asset.captionFor": "Légende de {filename}",
|
|
87
|
+
"asset.couldNotLoad": "Le fichier référencé n’a pas pu être chargé",
|
|
88
|
+
"asset.file": "FICHIER",
|
|
89
|
+
"asset.label": "{collection} : {filename}",
|
|
90
|
+
"asset.remove": "Supprimer le fichier",
|
|
91
|
+
"asset.replace": "Remplacer le fichier",
|
|
92
|
+
"asset.unavailable": "Indisponible",
|
|
93
|
+
"editor.addBlock": "Ajouter un bloc",
|
|
94
|
+
"editor.addLink": "Ajouter un lien",
|
|
95
|
+
"editor.apply": "Appliquer",
|
|
96
|
+
"editor.blockActions": "Actions du bloc",
|
|
97
|
+
"editor.blockAlreadyFirst": "Ce bloc est déjà le premier.",
|
|
98
|
+
"editor.blockAlreadyLast": "Ce bloc est déjà le dernier.",
|
|
99
|
+
"editor.blockMovedPosition": "Bloc déplacé à la position {position} sur {total}.",
|
|
100
|
+
"editor.bold": "Gras",
|
|
101
|
+
"editor.cancel": "Annuler",
|
|
102
|
+
"editor.dragToMoveBlock": "Faire glisser pour déplacer le bloc",
|
|
103
|
+
"editor.editLink": "Modifier le lien",
|
|
104
|
+
"editor.editNamed": "Modifier {name}",
|
|
105
|
+
"editor.empty": "Aucun contenu",
|
|
106
|
+
"editor.filterBlocks": "Filtrer les blocs",
|
|
107
|
+
"editor.filterBlocksPlaceholder": "Filtrer les blocs…",
|
|
108
|
+
"editor.footerHint": "— saisir / pour les commandes · déplacer avec Alt+Maj+↑/↓",
|
|
109
|
+
"editor.formatText": "Mise en forme du texte",
|
|
110
|
+
"editor.inlineCode": "Code en ligne",
|
|
111
|
+
"editor.insertBlock": "Insérer un bloc",
|
|
112
|
+
"editor.italic": "Italique",
|
|
113
|
+
"editor.linkURL": "URL du lien",
|
|
114
|
+
"editor.noMatchingBlocks": "Aucun bloc correspondant",
|
|
115
|
+
"editor.placeholder": "Rédigez le contenu ou saisissez / pour les commandes…",
|
|
116
|
+
"editor.remove": "Supprimer",
|
|
117
|
+
"editor.removeNamed": "Supprimer {name}",
|
|
118
|
+
"editor.replaceNamed": "Remplacer {name}",
|
|
119
|
+
"editor.strikethrough": "Barré",
|
|
120
|
+
"editor.subscript": "Indice",
|
|
121
|
+
"editor.superscript": "Exposant",
|
|
122
|
+
"editor.underline": "Souligné",
|
|
123
|
+
"option.bulletedList.description": "Liste non ordonnée",
|
|
124
|
+
"option.bulletedList.keywords": "liste puces non ordonnée ul",
|
|
125
|
+
"option.bulletedList.label": "Liste à puces",
|
|
126
|
+
"option.checklist.description": "Tâches avec cases à cocher",
|
|
127
|
+
"option.checklist.keywords": "liste tâches cases cocher todo",
|
|
128
|
+
"option.checklist.label": "Liste de tâches",
|
|
129
|
+
"option.codeBlock.description": "Code préformaté",
|
|
130
|
+
"option.codeBlock.keywords": "code bloc préformaté pre",
|
|
131
|
+
"option.codeBlock.label": "Bloc de code",
|
|
132
|
+
"option.divider.description": "Ligne horizontale",
|
|
133
|
+
"option.divider.keywords": "séparateur ligne horizontale hr",
|
|
134
|
+
"option.divider.label": "Séparateur",
|
|
135
|
+
"option.heading2.description": "Titre de section",
|
|
136
|
+
"option.heading2.keywords": "titre section en-tête h2",
|
|
137
|
+
"option.heading2.label": "Titre 2",
|
|
138
|
+
"option.heading3.description": "Titre de sous-section",
|
|
139
|
+
"option.heading3.keywords": "titre sous-section sous-titre h3",
|
|
140
|
+
"option.heading3.label": "Titre 3",
|
|
141
|
+
"option.numberedList.description": "Liste ordonnée",
|
|
142
|
+
"option.numberedList.keywords": "liste numérotée ordonnée ol",
|
|
143
|
+
"option.numberedList.label": "Liste numérotée",
|
|
144
|
+
"option.paragraph.description": "Texte courant",
|
|
145
|
+
"option.paragraph.keywords": "paragraphe texte normal p",
|
|
146
|
+
"option.paragraph.label": "Paragraphe",
|
|
147
|
+
"option.quote.description": "Passage cité",
|
|
148
|
+
"option.quote.keywords": "citation extrait bloc",
|
|
149
|
+
"option.quote.label": "Citation",
|
|
150
|
+
"option.relationship.description": "Référencer un élément {label} existant",
|
|
151
|
+
"option.relationship.keywords": "relation référence document associé",
|
|
152
|
+
"option.relationship.label": "{label} associé",
|
|
153
|
+
"option.upload.description": "Sélectionner ou téléverser {label}",
|
|
154
|
+
"option.upload.keywords": "téléverser image photo média fichier ressource",
|
|
155
|
+
"relationship.actions": "Actions sur la relation",
|
|
156
|
+
"relationship.label": "{collection} : {title}",
|
|
157
|
+
"relationship.remove": "Supprimer",
|
|
158
|
+
"relationship.replace": "Remplacer",
|
|
159
|
+
"relationship.unavailable": "Référence indisponible",
|
|
160
|
+
},
|
|
161
|
+
ar: {
|
|
162
|
+
"asset.actions": "إجراءات الملف",
|
|
163
|
+
"asset.addCaption": "إضافة وصف…",
|
|
164
|
+
"asset.captionFor": "وصف {filename}",
|
|
165
|
+
"asset.couldNotLoad": "تعذّر تحميل الملف المشار إليه",
|
|
166
|
+
"asset.file": "ملف",
|
|
167
|
+
"asset.label": "{collection}: {filename}",
|
|
168
|
+
"asset.remove": "إزالة الملف",
|
|
169
|
+
"asset.replace": "استبدال الملف",
|
|
170
|
+
"asset.unavailable": "غير متاح",
|
|
171
|
+
"editor.addBlock": "إضافة كتلة",
|
|
172
|
+
"editor.addLink": "إضافة رابط",
|
|
173
|
+
"editor.apply": "تطبيق",
|
|
174
|
+
"editor.blockActions": "إجراءات الكتلة",
|
|
175
|
+
"editor.blockAlreadyFirst": "هذه الكتلة هي الأولى بالفعل.",
|
|
176
|
+
"editor.blockAlreadyLast": "هذه الكتلة هي الأخيرة بالفعل.",
|
|
177
|
+
"editor.blockMovedPosition": "نُقلت الكتلة إلى الموضع {position} من {total}.",
|
|
178
|
+
"editor.bold": "عريض",
|
|
179
|
+
"editor.cancel": "إلغاء",
|
|
180
|
+
"editor.dragToMoveBlock": "اسحب لنقل الكتلة",
|
|
181
|
+
"editor.editLink": "تعديل الرابط",
|
|
182
|
+
"editor.editNamed": "تعديل {name}",
|
|
183
|
+
"editor.empty": "لا يوجد محتوى",
|
|
184
|
+
"editor.filterBlocks": "تصفية الكتل",
|
|
185
|
+
"editor.filterBlocksPlaceholder": "تصفية الكتل…",
|
|
186
|
+
"editor.footerHint": "— اكتب / لعرض الأوامر · انقل باستخدام Alt+Shift+↑/↓",
|
|
187
|
+
"editor.formatText": "تنسيق النص",
|
|
188
|
+
"editor.inlineCode": "شفرة مضمنة",
|
|
189
|
+
"editor.insertBlock": "إدراج كتلة",
|
|
190
|
+
"editor.italic": "مائل",
|
|
191
|
+
"editor.linkURL": "رابط URL",
|
|
192
|
+
"editor.noMatchingBlocks": "لا توجد كتل مطابقة",
|
|
193
|
+
"editor.placeholder": "اكتب المحتوى أو اكتب / لعرض الأوامر…",
|
|
194
|
+
"editor.remove": "إزالة",
|
|
195
|
+
"editor.removeNamed": "إزالة {name}",
|
|
196
|
+
"editor.replaceNamed": "استبدال {name}",
|
|
197
|
+
"editor.strikethrough": "يتوسطه خط",
|
|
198
|
+
"editor.subscript": "منخفض",
|
|
199
|
+
"editor.superscript": "مرتفع",
|
|
200
|
+
"editor.underline": "تحته خط",
|
|
201
|
+
"option.bulletedList.description": "قائمة غير مرتبة",
|
|
202
|
+
"option.bulletedList.keywords": "قائمة نقطية غير مرتبة ul",
|
|
203
|
+
"option.bulletedList.label": "قائمة نقطية",
|
|
204
|
+
"option.checklist.description": "مهام مع مربعات اختيار",
|
|
205
|
+
"option.checklist.keywords": "قائمة مهام مربعات اختيار todo",
|
|
206
|
+
"option.checklist.label": "قائمة تحقق",
|
|
207
|
+
"option.codeBlock.description": "شفرة منسقة مسبقًا",
|
|
208
|
+
"option.codeBlock.keywords": "شفرة كتلة منسقة مسبقًا pre",
|
|
209
|
+
"option.codeBlock.label": "كتلة شفرة",
|
|
210
|
+
"option.divider.description": "خط أفقي",
|
|
211
|
+
"option.divider.keywords": "فاصل خط أفقي hr",
|
|
212
|
+
"option.divider.label": "فاصل",
|
|
213
|
+
"option.heading2.description": "عنوان قسم",
|
|
214
|
+
"option.heading2.keywords": "عنوان قسم رئيسي h2",
|
|
215
|
+
"option.heading2.label": "عنوان 2",
|
|
216
|
+
"option.heading3.description": "عنوان قسم فرعي",
|
|
217
|
+
"option.heading3.keywords": "عنوان قسم فرعي h3",
|
|
218
|
+
"option.heading3.label": "عنوان 3",
|
|
219
|
+
"option.numberedList.description": "قائمة مرتبة",
|
|
220
|
+
"option.numberedList.keywords": "قائمة مرقمة مرتبة ol",
|
|
221
|
+
"option.numberedList.label": "قائمة مرقمة",
|
|
222
|
+
"option.paragraph.description": "نص عادي",
|
|
223
|
+
"option.paragraph.keywords": "فقرة نص عادي p",
|
|
224
|
+
"option.paragraph.label": "فقرة",
|
|
225
|
+
"option.quote.description": "نص مقتبس",
|
|
226
|
+
"option.quote.keywords": "اقتباس نص مقتبس",
|
|
227
|
+
"option.quote.label": "اقتباس",
|
|
228
|
+
"option.relationship.description": "الإشارة إلى {label} موجود",
|
|
229
|
+
"option.relationship.keywords": "علاقة مرجع مستند مرتبط",
|
|
230
|
+
"option.relationship.label": "{label} مرتبط",
|
|
231
|
+
"option.upload.description": "اختيار أو رفع {label}",
|
|
232
|
+
"option.upload.keywords": "رفع صورة وسائط ملف أصل",
|
|
233
|
+
"relationship.actions": "إجراءات العلاقة",
|
|
234
|
+
"relationship.label": "{collection}: {title}",
|
|
235
|
+
"relationship.remove": "إزالة",
|
|
236
|
+
"relationship.replace": "استبدال",
|
|
237
|
+
"relationship.unavailable": "مرجع غير متاح",
|
|
238
|
+
},
|
|
239
|
+
},
|
|
240
|
+
});
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import {
|
|
3
|
+
useLexicalComposerContext,
|
|
4
|
+
useLexicalEditable,
|
|
5
|
+
useLexicalNodeSelection,
|
|
6
|
+
} from "@hvniel/lexical-svelte";
|
|
7
|
+
import { getAdminI18n } from "@riducms/plugin";
|
|
8
|
+
import type { NodeKey } from "lexical";
|
|
9
|
+
|
|
10
|
+
import { getRichTextAuthoringHost } from "@plugin-richtext/field/rich-text-context.svelte";
|
|
11
|
+
import {
|
|
12
|
+
OPEN_RELATIONSHIP_BROWSER_COMMAND,
|
|
13
|
+
REMOVE_RELATIONSHIP_COMMAND,
|
|
14
|
+
} from "@plugin-richtext/menu/rich-text-commands";
|
|
15
|
+
|
|
16
|
+
let {
|
|
17
|
+
documentID,
|
|
18
|
+
nodeKey,
|
|
19
|
+
relationTo,
|
|
20
|
+
}: { documentID: string; nodeKey: NodeKey; relationTo: string } = $props();
|
|
21
|
+
const editor = useLexicalComposerContext()[0];
|
|
22
|
+
const isEditable = useLexicalEditable();
|
|
23
|
+
const i18n = getAdminI18n();
|
|
24
|
+
// A decorator node key is fixed for this component's lifetime.
|
|
25
|
+
// svelte-ignore state_referenced_locally
|
|
26
|
+
const [isSelected, setSelected, clearSelection] = useLexicalNodeSelection(nodeKey);
|
|
27
|
+
const authoring = getRichTextAuthoringHost();
|
|
28
|
+
let document = $state.raw<Record<string, unknown>>();
|
|
29
|
+
let loadError = $state(false);
|
|
30
|
+
const collection = $derived(
|
|
31
|
+
authoring?.collections.find((candidate) => candidate.slug === relationTo)
|
|
32
|
+
);
|
|
33
|
+
const titleField = $derived(
|
|
34
|
+
collection?.admin.useAsTitle ??
|
|
35
|
+
collection?.fields.find((field) => field.name === "title" || field.name === "name")?.name
|
|
36
|
+
);
|
|
37
|
+
const title = $derived(
|
|
38
|
+
titleField !== undefined && typeof document?.[titleField] === "string"
|
|
39
|
+
? String(document[titleField])
|
|
40
|
+
: documentID
|
|
41
|
+
);
|
|
42
|
+
|
|
43
|
+
$effect(() => {
|
|
44
|
+
if (authoring === undefined) return;
|
|
45
|
+
void authoring.documentRevision;
|
|
46
|
+
const request = new AbortController();
|
|
47
|
+
authoring
|
|
48
|
+
.findDocument(relationTo, documentID, request.signal)
|
|
49
|
+
.then((value) => {
|
|
50
|
+
if (request.signal.aborted) return;
|
|
51
|
+
document = value;
|
|
52
|
+
loadError = false;
|
|
53
|
+
})
|
|
54
|
+
.catch(() => {
|
|
55
|
+
if (request.signal.aborted) return;
|
|
56
|
+
loadError = true;
|
|
57
|
+
});
|
|
58
|
+
return () => request.abort();
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
function selectNode(event?: MouseEvent) {
|
|
62
|
+
if (event?.shiftKey !== true) clearSelection();
|
|
63
|
+
setSelected(true);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function openDocument(event: MouseEvent) {
|
|
67
|
+
selectNode(event);
|
|
68
|
+
editor.dispatchCommand(OPEN_RELATIONSHIP_BROWSER_COMMAND, {
|
|
69
|
+
collectionSlug: relationTo,
|
|
70
|
+
documentID,
|
|
71
|
+
mode: "edit",
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function replaceDocument() {
|
|
76
|
+
editor.dispatchCommand(OPEN_RELATIONSHIP_BROWSER_COMMAND, {
|
|
77
|
+
collectionSlug: relationTo,
|
|
78
|
+
documentID,
|
|
79
|
+
mode: "replace",
|
|
80
|
+
nodeKey,
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function removeRelationship() {
|
|
85
|
+
editor.dispatchCommand(REMOVE_RELATIONSHIP_COMMAND, nodeKey);
|
|
86
|
+
queueMicrotask(() => editor.focus());
|
|
87
|
+
}
|
|
88
|
+
</script>
|
|
89
|
+
|
|
90
|
+
<article
|
|
91
|
+
class={[
|
|
92
|
+
"group/relationship relative my-6 flex items-center gap-3 rounded-[4px] border border-control-border bg-control px-4 py-3.5 transition-colors hover:border-primary/45",
|
|
93
|
+
isSelected() && "border-primary/50",
|
|
94
|
+
]}
|
|
95
|
+
role="group"
|
|
96
|
+
aria-label={i18n.t("plugin.richtext:relationship.label", {
|
|
97
|
+
collection: collection?.labels.singular ?? relationTo,
|
|
98
|
+
title,
|
|
99
|
+
})}
|
|
100
|
+
>
|
|
101
|
+
<span
|
|
102
|
+
class="font-mono grid size-9 shrink-0 place-items-center rounded-full bg-primary/10 text-[11px] font-semibold text-brand-primary-soft"
|
|
103
|
+
aria-hidden="true"
|
|
104
|
+
>
|
|
105
|
+
{(collection?.labels.singular ?? relationTo).slice(0, 2).toLocaleUpperCase(i18n.language)}
|
|
106
|
+
</span>
|
|
107
|
+
<button
|
|
108
|
+
type="button"
|
|
109
|
+
class="min-w-0 flex-1 cursor-pointer border-0 bg-transparent p-0 text-start outline-none"
|
|
110
|
+
disabled={!isEditable()}
|
|
111
|
+
onclick={openDocument}
|
|
112
|
+
>
|
|
113
|
+
<span class="block truncate text-[13.5px] font-medium text-foreground-strong">
|
|
114
|
+
{loadError ? i18n.t("plugin.richtext:relationship.unavailable") : title}
|
|
115
|
+
</span>
|
|
116
|
+
<span class="font-mono mt-0.5 block truncate text-[10px] text-foreground-faint">
|
|
117
|
+
{collection?.labels.singular ?? relationTo} · {documentID}
|
|
118
|
+
</span>
|
|
119
|
+
</button>
|
|
120
|
+
{#if isEditable()}
|
|
121
|
+
<div
|
|
122
|
+
class="flex shrink-0 items-center gap-1"
|
|
123
|
+
aria-label={i18n.t("plugin.richtext:relationship.actions")}
|
|
124
|
+
>
|
|
125
|
+
<button
|
|
126
|
+
type="button"
|
|
127
|
+
class="rounded-[3px] px-2 py-1.5 text-[11px] text-foreground-muted hover:bg-control-hover hover:text-foreground"
|
|
128
|
+
onclick={replaceDocument}
|
|
129
|
+
>
|
|
130
|
+
{i18n.t("plugin.richtext:relationship.replace")}
|
|
131
|
+
</button>
|
|
132
|
+
<button
|
|
133
|
+
type="button"
|
|
134
|
+
class="rounded-[3px] px-2 py-1.5 text-[11px] text-destructive hover:bg-destructive/10"
|
|
135
|
+
onclick={removeRelationship}
|
|
136
|
+
>
|
|
137
|
+
{i18n.t("plugin.richtext:relationship.remove")}
|
|
138
|
+
</button>
|
|
139
|
+
</div>
|
|
140
|
+
{/if}
|
|
141
|
+
</article>
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import type { Decorator } from "@hvniel/lexical-svelte";
|
|
2
|
+
import { DecoratorBlockNode } from "@hvniel/lexical-svelte";
|
|
3
|
+
import {
|
|
4
|
+
$applyNodeReplacement,
|
|
5
|
+
$getDocument,
|
|
6
|
+
type ElementFormatType,
|
|
7
|
+
type LexicalNode,
|
|
8
|
+
type LexicalUpdateJSON,
|
|
9
|
+
type NodeKey,
|
|
10
|
+
type SerializedLexicalNode,
|
|
11
|
+
type Spread,
|
|
12
|
+
} from "lexical";
|
|
13
|
+
|
|
14
|
+
import RichTextRelationshipNodeComponent from "@plugin-richtext/relationship/rich-text-relationship-node.svelte";
|
|
15
|
+
|
|
16
|
+
export type SerializedRelationshipNode = Spread<
|
|
17
|
+
{
|
|
18
|
+
format: ElementFormatType;
|
|
19
|
+
id: string;
|
|
20
|
+
relationTo: string;
|
|
21
|
+
},
|
|
22
|
+
SerializedLexicalNode
|
|
23
|
+
>;
|
|
24
|
+
|
|
25
|
+
export class RelationshipNode extends DecoratorBlockNode {
|
|
26
|
+
__relationTo: string;
|
|
27
|
+
__documentID: string;
|
|
28
|
+
|
|
29
|
+
static getType(): string {
|
|
30
|
+
return "relationship";
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
static clone(node: RelationshipNode): RelationshipNode {
|
|
34
|
+
return new RelationshipNode(node.__relationTo, node.__documentID, node.__format, node.__key);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
static importJSON(serializedNode: SerializedRelationshipNode): RelationshipNode {
|
|
38
|
+
return createRelationshipNode({
|
|
39
|
+
documentID: serializedNode.id,
|
|
40
|
+
format: serializedNode.format,
|
|
41
|
+
relationTo: serializedNode.relationTo,
|
|
42
|
+
}).updateFromJSON(serializedNode);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
constructor(relationTo: string, documentID: string, format?: ElementFormatType, key?: NodeKey) {
|
|
46
|
+
super(format, key);
|
|
47
|
+
this.__relationTo = relationTo;
|
|
48
|
+
this.__documentID = documentID;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
updateFromJSON(serializedNode: LexicalUpdateJSON<SerializedRelationshipNode>): this {
|
|
52
|
+
const node = super.updateFromJSON(serializedNode);
|
|
53
|
+
node.__relationTo = serializedNode.relationTo;
|
|
54
|
+
node.__documentID = serializedNode.id;
|
|
55
|
+
return node;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
exportJSON(): SerializedRelationshipNode {
|
|
59
|
+
return {
|
|
60
|
+
...super.exportJSON(),
|
|
61
|
+
id: this.__documentID,
|
|
62
|
+
relationTo: this.__relationTo,
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
createDOM(): HTMLElement {
|
|
67
|
+
const element = $getDocument().createElement("div");
|
|
68
|
+
element.className = "ridu-richtext-relationship";
|
|
69
|
+
return element;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
decorate(): Decorator<typeof RichTextRelationshipNodeComponent> {
|
|
73
|
+
return {
|
|
74
|
+
component: RichTextRelationshipNodeComponent,
|
|
75
|
+
props: {
|
|
76
|
+
documentID: this.__documentID,
|
|
77
|
+
nodeKey: this.getKey(),
|
|
78
|
+
relationTo: this.__relationTo,
|
|
79
|
+
},
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
setReference(relationTo: string, documentID: string): this {
|
|
84
|
+
const writable = this.getWritable();
|
|
85
|
+
writable.__relationTo = relationTo;
|
|
86
|
+
writable.__documentID = documentID;
|
|
87
|
+
return writable;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function createRelationshipNode({
|
|
92
|
+
documentID,
|
|
93
|
+
format,
|
|
94
|
+
relationTo,
|
|
95
|
+
}: {
|
|
96
|
+
documentID: string;
|
|
97
|
+
format?: ElementFormatType;
|
|
98
|
+
relationTo: string;
|
|
99
|
+
}): RelationshipNode {
|
|
100
|
+
return $applyNodeReplacement(new RelationshipNode(relationTo, documentID, format));
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export function isRelationshipNode(node: LexicalNode | null | undefined): node is RelationshipNode {
|
|
104
|
+
return node instanceof RelationshipNode;
|
|
105
|
+
}
|