@rapidmx/web-client 0.18.0 → 0.19.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.
|
@@ -15,6 +15,7 @@ import {
|
|
|
15
15
|
planPluginChange,
|
|
16
16
|
Plugin,
|
|
17
17
|
PluginChangePlan,
|
|
18
|
+
PluginConfiguredSetting,
|
|
18
19
|
PluginInstanceStatus,
|
|
19
20
|
PluginNamespace,
|
|
20
21
|
PluginPurgeInfo,
|
|
@@ -1315,13 +1316,39 @@ function suggestedHost(definition: PluginSettingDefinition, saved: PluginSetting
|
|
|
1315
1316
|
return unset ? definition.default.split(HOST_PLACEHOLDER).join(window.location.host) : undefined;
|
|
1316
1317
|
}
|
|
1317
1318
|
|
|
1318
|
-
/**
|
|
1319
|
-
|
|
1320
|
-
|
|
1319
|
+
/** What the deployment configures for a setting, as a form value: nothing for a secret, whose value the server keeps to itself. */
|
|
1320
|
+
function configuredValue(definition: PluginSettingDefinition, configured: PluginConfiguredSetting): PluginSettingValue | "" {
|
|
1321
|
+
if (definition.type === "boolean") {
|
|
1322
|
+
return configured.value === true;
|
|
1323
|
+
}
|
|
1324
|
+
return configured.value === undefined ? "" : String(configured.value);
|
|
1325
|
+
}
|
|
1326
|
+
|
|
1327
|
+
/** Whether a saved value is absent - nothing, or the empty value a plugin's own "" default is saved as. */
|
|
1328
|
+
function isUnsaved(saved: PluginSettingValue | undefined): boolean {
|
|
1329
|
+
return saved === undefined || saved === "";
|
|
1330
|
+
}
|
|
1331
|
+
|
|
1332
|
+
/** The suggestion for the host, unless the deployment configures the setting, which is offered instead. */
|
|
1333
|
+
function hostSuggestion(definition: PluginSettingDefinition, saved: PluginSettingValue | undefined, configured: PluginConfiguredSetting | undefined): string | undefined {
|
|
1334
|
+
return configured ? undefined : suggestedHost(definition, saved);
|
|
1335
|
+
}
|
|
1336
|
+
|
|
1337
|
+
/** A setting's current form value: its saved value (which wins over the deployment's configuration); else what the
|
|
1338
|
+
* deployment configures; else the setting's own default - or, for a required select with none of those, its first
|
|
1339
|
+
* option. Kept as a string for text inputs. */
|
|
1340
|
+
function initialValue(
|
|
1341
|
+
definition: PluginSettingDefinition,
|
|
1342
|
+
saved: PluginSettingValue | undefined,
|
|
1343
|
+
configured?: PluginConfiguredSetting,
|
|
1344
|
+
): PluginSettingValue | "" {
|
|
1345
|
+
if (configured && isUnsaved(saved)) {
|
|
1346
|
+
return configuredValue(definition, configured);
|
|
1347
|
+
}
|
|
1321
1348
|
if (needsSelection(definition, saved)) {
|
|
1322
1349
|
return definition.options![0].value;
|
|
1323
1350
|
}
|
|
1324
|
-
const suggestion =
|
|
1351
|
+
const suggestion = hostSuggestion(definition, saved, configured);
|
|
1325
1352
|
if (suggestion !== undefined) {
|
|
1326
1353
|
return suggestion;
|
|
1327
1354
|
}
|
|
@@ -1335,29 +1362,53 @@ function initialValue(definition: PluginSettingDefinition, saved: PluginSettingV
|
|
|
1335
1362
|
function SettingsModal({ plugin, onClose, onSaved }: { plugin: Plugin; onClose: () => void; onSaved: (plugin: Plugin) => void }) {
|
|
1336
1363
|
// Only opened from the Settings button, which is only shown for a plugin that declares settings.
|
|
1337
1364
|
const definitions: PluginSettingDefinition[] = plugin.manifest.settings!;
|
|
1338
|
-
const initial = useRef(Object.fromEntries(definitions.map((d) => [d.key, initialValue(d, plugin.settings[d.key])])));
|
|
1365
|
+
const initial = useRef(Object.fromEntries(definitions.map((d) => [d.key, initialValue(d, plugin.settings[d.key], plugin.configured?.[d.key])])));
|
|
1339
1366
|
const [values, setValues] = useState<Record<string, PluginSettingValue | "">>(initial.current);
|
|
1367
|
+
// Set by Reset: saving then drops every saved value, so the deployment's configuration (or the default) applies again.
|
|
1368
|
+
const [resetting, setResetting] = useState(false);
|
|
1340
1369
|
const [error, setError] = useState<string | null>(null);
|
|
1341
1370
|
const [busy, setBusy] = useState(false);
|
|
1342
1371
|
|
|
1372
|
+
/** Puts every field back to what it shows with nothing saved. Nothing is stored until the form is saved. */
|
|
1373
|
+
function reset() {
|
|
1374
|
+
setValues(Object.fromEntries(definitions.map((d) => [d.key, initialValue(d, undefined, plugin.configured?.[d.key])])));
|
|
1375
|
+
setResetting(true);
|
|
1376
|
+
setError(null);
|
|
1377
|
+
}
|
|
1378
|
+
|
|
1343
1379
|
async function save(e: FormEvent) {
|
|
1344
1380
|
e.preventDefault();
|
|
1345
1381
|
// The server replaces the whole settings object, so every setting is sent. One left alone is sent as it's saved
|
|
1346
1382
|
// - `null` when nothing is, so it keeps following the plugin's default rather than pinning the default shown.
|
|
1347
1383
|
// A required select with nothing to fall back on, and a setting offering this console's host, are sent as the
|
|
1348
|
-
// form shows them.
|
|
1384
|
+
// form shows them. What the deployment configures is never saved as a copy, which would outrank it when it changes
|
|
1385
|
+
// - a field that shows it is sent as nothing.
|
|
1349
1386
|
const settings: Record<string, PluginSettingValue | null> = {};
|
|
1350
1387
|
let changed = false;
|
|
1351
1388
|
for (const definition of definitions) {
|
|
1352
1389
|
const key: string = definition.key;
|
|
1390
|
+
const configured: PluginConfiguredSetting | undefined = plugin.configured?.[key];
|
|
1391
|
+
const saved: PluginSettingValue | undefined = plugin.settings[key];
|
|
1353
1392
|
const value = values[key];
|
|
1354
|
-
if (value ===
|
|
1355
|
-
|
|
1393
|
+
if (resetting && value === initialValue(definition, undefined, configured)) {
|
|
1394
|
+
// What a fresh install saves: only a default naming this console's host has to be, for the plugin to work.
|
|
1395
|
+
// A saved value that already is the default shown stays, since dropping it changes nothing.
|
|
1396
|
+
settings[key] = !configured && saved !== undefined && String(saved) === String(value) ? saved : (hostSuggestion(definition, undefined, configured) ?? null);
|
|
1397
|
+
changed ||= settings[key] !== (saved ?? null);
|
|
1398
|
+
continue;
|
|
1399
|
+
}
|
|
1400
|
+
if (!resetting && value === initial.current[key] && !needsSelection(definition, saved) && hostSuggestion(definition, saved, configured) === undefined) {
|
|
1401
|
+
settings[key] = saved ?? null;
|
|
1356
1402
|
continue;
|
|
1357
1403
|
}
|
|
1358
1404
|
changed = true;
|
|
1359
1405
|
// A number input only ever reports a valid number or an empty string.
|
|
1360
|
-
settings[key] =
|
|
1406
|
+
settings[key] =
|
|
1407
|
+
value === "" || (configured && !configured.secret && String(value) === String(configured.value))
|
|
1408
|
+
? null
|
|
1409
|
+
: definition.type === "number"
|
|
1410
|
+
? Number(value)
|
|
1411
|
+
: value;
|
|
1361
1412
|
}
|
|
1362
1413
|
if (!changed) {
|
|
1363
1414
|
onClose();
|
|
@@ -1383,14 +1434,22 @@ function SettingsModal({ plugin, onClose, onSaved }: { plugin: Plugin; onClose:
|
|
|
1383
1434
|
key={definition.key}
|
|
1384
1435
|
definition={definition}
|
|
1385
1436
|
value={values[definition.key]}
|
|
1437
|
+
configured={plugin.configured?.[definition.key]}
|
|
1386
1438
|
onChange={(value) => setValues((prev) => ({ ...prev, [definition.key]: value }))}
|
|
1387
1439
|
/>
|
|
1388
1440
|
))}
|
|
1389
1441
|
{/* Kept in view at the bottom of the dialog while a long list of settings scrolls. The dialog scrolls
|
|
1390
1442
|
inside its padding, so the bar reaches past it to the dialog's edges. */}
|
|
1391
1443
|
<div className="sticky -bottom-7 -mx-7 -mb-7 px-7 py-4 bg-surface border-t border-border flex flex-col gap-3">
|
|
1392
|
-
<p className="text-xs text-text-muted">
|
|
1444
|
+
<p className="text-xs text-text-muted">
|
|
1445
|
+
{resetting
|
|
1446
|
+
? "Every setting is back to what this deployment's configuration or the plugin's default gives. Save to apply that."
|
|
1447
|
+
: "Saving restarts the servers one at a time to apply the new settings."}
|
|
1448
|
+
</p>
|
|
1393
1449
|
<div className="flex gap-2 justify-end">
|
|
1450
|
+
<Button type="button" variant="secondary" className="!w-auto mr-auto" onClick={reset} disabled={busy}>
|
|
1451
|
+
Reset
|
|
1452
|
+
</Button>
|
|
1394
1453
|
<Button type="button" variant="secondary" className="!w-auto" onClick={onClose}>
|
|
1395
1454
|
Cancel
|
|
1396
1455
|
</Button>
|
|
@@ -1407,13 +1466,28 @@ function SettingsModal({ plugin, onClose, onSaved }: { plugin: Plugin; onClose:
|
|
|
1407
1466
|
function SettingField({
|
|
1408
1467
|
definition,
|
|
1409
1468
|
value,
|
|
1469
|
+
configured,
|
|
1410
1470
|
onChange,
|
|
1411
1471
|
}: {
|
|
1412
1472
|
definition: PluginSettingDefinition;
|
|
1413
1473
|
value: PluginSettingValue | "";
|
|
1474
|
+
configured?: PluginConfiguredSetting;
|
|
1414
1475
|
onChange: (value: PluginSettingValue | "") => void;
|
|
1415
1476
|
}) {
|
|
1416
|
-
|
|
1477
|
+
// What applies without a saved value; one saved here replaces it, and Reset goes back to it.
|
|
1478
|
+
const note = configured && (
|
|
1479
|
+
<span className="text-xs text-text-muted">
|
|
1480
|
+
This deployment's configuration provides {configured.secret ? "a value" : "the value shown"} unless one is saved here.
|
|
1481
|
+
</span>
|
|
1482
|
+
);
|
|
1483
|
+
// A secret's value never reaches the browser, so the field only says that it is set.
|
|
1484
|
+
const placeholder: string | undefined = configured?.secret ? "Set by this deployment" : undefined;
|
|
1485
|
+
const help = (
|
|
1486
|
+
<>
|
|
1487
|
+
{definition.help && <span className="text-xs text-text-muted">{definition.help}</span>}
|
|
1488
|
+
{note}
|
|
1489
|
+
</>
|
|
1490
|
+
);
|
|
1417
1491
|
if (definition.type === "boolean") {
|
|
1418
1492
|
// Laid out like the admin policy forms' checkboxes: the help sits under the label, beside the box.
|
|
1419
1493
|
return (
|
|
@@ -1445,7 +1519,8 @@ function SettingField({
|
|
|
1445
1519
|
type={definition.type === "number" ? "number" : "text"}
|
|
1446
1520
|
min={definition.min}
|
|
1447
1521
|
max={definition.max}
|
|
1448
|
-
required={definition.required}
|
|
1522
|
+
required={definition.required && !configured}
|
|
1523
|
+
placeholder={placeholder}
|
|
1449
1524
|
value={String(value)}
|
|
1450
1525
|
onChange={(e) => onChange(e.target.value)}
|
|
1451
1526
|
/>
|
|
@@ -616,13 +616,32 @@ function suggestedHost(definition, saved) {
|
|
|
616
616
|
const unset = saved === undefined || saved === "" || (typeof saved === "string" && saved.includes(HOST_PLACEHOLDER));
|
|
617
617
|
return unset ? definition.default.split(HOST_PLACEHOLDER).join(window.location.host) : undefined;
|
|
618
618
|
}
|
|
619
|
-
/**
|
|
620
|
-
|
|
621
|
-
|
|
619
|
+
/** What the deployment configures for a setting, as a form value: nothing for a secret, whose value the server keeps to itself. */
|
|
620
|
+
function configuredValue(definition, configured) {
|
|
621
|
+
if (definition.type === "boolean") {
|
|
622
|
+
return configured.value === true;
|
|
623
|
+
}
|
|
624
|
+
return configured.value === undefined ? "" : String(configured.value);
|
|
625
|
+
}
|
|
626
|
+
/** Whether a saved value is absent - nothing, or the empty value a plugin's own "" default is saved as. */
|
|
627
|
+
function isUnsaved(saved) {
|
|
628
|
+
return saved === undefined || saved === "";
|
|
629
|
+
}
|
|
630
|
+
/** The suggestion for the host, unless the deployment configures the setting, which is offered instead. */
|
|
631
|
+
function hostSuggestion(definition, saved, configured) {
|
|
632
|
+
return configured ? undefined : suggestedHost(definition, saved);
|
|
633
|
+
}
|
|
634
|
+
/** A setting's current form value: its saved value (which wins over the deployment's configuration); else what the
|
|
635
|
+
* deployment configures; else the setting's own default - or, for a required select with none of those, its first
|
|
636
|
+
* option. Kept as a string for text inputs. */
|
|
637
|
+
function initialValue(definition, saved, configured) {
|
|
638
|
+
if (configured && isUnsaved(saved)) {
|
|
639
|
+
return configuredValue(definition, configured);
|
|
640
|
+
}
|
|
622
641
|
if (needsSelection(definition, saved)) {
|
|
623
642
|
return definition.options[0].value;
|
|
624
643
|
}
|
|
625
|
-
const suggestion =
|
|
644
|
+
const suggestion = hostSuggestion(definition, saved, configured);
|
|
626
645
|
if (suggestion !== undefined) {
|
|
627
646
|
return suggestion;
|
|
628
647
|
}
|
|
@@ -635,28 +654,51 @@ function initialValue(definition, saved) {
|
|
|
635
654
|
function SettingsModal({ plugin, onClose, onSaved }) {
|
|
636
655
|
// Only opened from the Settings button, which is only shown for a plugin that declares settings.
|
|
637
656
|
const definitions = plugin.manifest.settings;
|
|
638
|
-
const initial = useRef(Object.fromEntries(definitions.map((d) => [d.key, initialValue(d, plugin.settings[d.key])])));
|
|
657
|
+
const initial = useRef(Object.fromEntries(definitions.map((d) => [d.key, initialValue(d, plugin.settings[d.key], plugin.configured?.[d.key])])));
|
|
639
658
|
const [values, setValues] = useState(initial.current);
|
|
659
|
+
// Set by Reset: saving then drops every saved value, so the deployment's configuration (or the default) applies again.
|
|
660
|
+
const [resetting, setResetting] = useState(false);
|
|
640
661
|
const [error, setError] = useState(null);
|
|
641
662
|
const [busy, setBusy] = useState(false);
|
|
663
|
+
/** Puts every field back to what it shows with nothing saved. Nothing is stored until the form is saved. */
|
|
664
|
+
function reset() {
|
|
665
|
+
setValues(Object.fromEntries(definitions.map((d) => [d.key, initialValue(d, undefined, plugin.configured?.[d.key])])));
|
|
666
|
+
setResetting(true);
|
|
667
|
+
setError(null);
|
|
668
|
+
}
|
|
642
669
|
async function save(e) {
|
|
643
670
|
e.preventDefault();
|
|
644
671
|
// The server replaces the whole settings object, so every setting is sent. One left alone is sent as it's saved
|
|
645
672
|
// - `null` when nothing is, so it keeps following the plugin's default rather than pinning the default shown.
|
|
646
673
|
// A required select with nothing to fall back on, and a setting offering this console's host, are sent as the
|
|
647
|
-
// form shows them.
|
|
674
|
+
// form shows them. What the deployment configures is never saved as a copy, which would outrank it when it changes
|
|
675
|
+
// - a field that shows it is sent as nothing.
|
|
648
676
|
const settings = {};
|
|
649
677
|
let changed = false;
|
|
650
678
|
for (const definition of definitions) {
|
|
651
679
|
const key = definition.key;
|
|
680
|
+
const configured = plugin.configured?.[key];
|
|
681
|
+
const saved = plugin.settings[key];
|
|
652
682
|
const value = values[key];
|
|
653
|
-
if (value ===
|
|
654
|
-
|
|
683
|
+
if (resetting && value === initialValue(definition, undefined, configured)) {
|
|
684
|
+
// What a fresh install saves: only a default naming this console's host has to be, for the plugin to work.
|
|
685
|
+
// A saved value that already is the default shown stays, since dropping it changes nothing.
|
|
686
|
+
settings[key] = !configured && saved !== undefined && String(saved) === String(value) ? saved : (hostSuggestion(definition, undefined, configured) ?? null);
|
|
687
|
+
changed || (changed = settings[key] !== (saved ?? null));
|
|
688
|
+
continue;
|
|
689
|
+
}
|
|
690
|
+
if (!resetting && value === initial.current[key] && !needsSelection(definition, saved) && hostSuggestion(definition, saved, configured) === undefined) {
|
|
691
|
+
settings[key] = saved ?? null;
|
|
655
692
|
continue;
|
|
656
693
|
}
|
|
657
694
|
changed = true;
|
|
658
695
|
// A number input only ever reports a valid number or an empty string.
|
|
659
|
-
settings[key] =
|
|
696
|
+
settings[key] =
|
|
697
|
+
value === "" || (configured && !configured.secret && String(value) === String(configured.value))
|
|
698
|
+
? null
|
|
699
|
+
: definition.type === "number"
|
|
700
|
+
? Number(value)
|
|
701
|
+
: value;
|
|
660
702
|
}
|
|
661
703
|
if (!changed) {
|
|
662
704
|
onClose();
|
|
@@ -674,15 +716,21 @@ function SettingsModal({ plugin, onClose, onSaved }) {
|
|
|
674
716
|
setBusy(false);
|
|
675
717
|
}
|
|
676
718
|
}
|
|
677
|
-
return (_jsxs(Modal, { open: true, onClose: onClose, title: `${plugin.manifest.displayName} settings`, children: [error && _jsx(Alert, { children: error }), _jsxs("form", { onSubmit: save, className: "flex flex-col gap-5", children: [definitions.map((definition) => (_jsx(SettingField, { definition: definition, value: values[definition.key], onChange: (value) => setValues((prev) => ({ ...prev, [definition.key]: value })) }, definition.key))), _jsxs("div", { className: "sticky -bottom-7 -mx-7 -mb-7 px-7 py-4 bg-surface border-t border-border flex flex-col gap-3", children: [_jsx("p", { className: "text-xs text-text-muted", children:
|
|
719
|
+
return (_jsxs(Modal, { open: true, onClose: onClose, title: `${plugin.manifest.displayName} settings`, children: [error && _jsx(Alert, { children: error }), _jsxs("form", { onSubmit: save, className: "flex flex-col gap-5", children: [definitions.map((definition) => (_jsx(SettingField, { definition: definition, value: values[definition.key], configured: plugin.configured?.[definition.key], onChange: (value) => setValues((prev) => ({ ...prev, [definition.key]: value })) }, definition.key))), _jsxs("div", { className: "sticky -bottom-7 -mx-7 -mb-7 px-7 py-4 bg-surface border-t border-border flex flex-col gap-3", children: [_jsx("p", { className: "text-xs text-text-muted", children: resetting
|
|
720
|
+
? "Every setting is back to what this deployment's configuration or the plugin's default gives. Save to apply that."
|
|
721
|
+
: "Saving restarts the servers one at a time to apply the new settings." }), _jsxs("div", { className: "flex gap-2 justify-end", children: [_jsx(Button, { type: "button", variant: "secondary", className: "!w-auto mr-auto", onClick: reset, disabled: busy, children: "Reset" }), _jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", onClick: onClose, children: "Cancel" }), _jsx(Button, { type: "submit", className: "!w-auto", loading: busy, disabled: busy, children: "Save" })] })] })] })] }));
|
|
678
722
|
}
|
|
679
|
-
function SettingField({ definition, value, onChange, }) {
|
|
680
|
-
|
|
723
|
+
function SettingField({ definition, value, configured, onChange, }) {
|
|
724
|
+
// What applies without a saved value; one saved here replaces it, and Reset goes back to it.
|
|
725
|
+
const note = configured && (_jsxs("span", { className: "text-xs text-text-muted", children: ["This deployment's configuration provides ", configured.secret ? "a value" : "the value shown", " unless one is saved here."] }));
|
|
726
|
+
// A secret's value never reaches the browser, so the field only says that it is set.
|
|
727
|
+
const placeholder = configured?.secret ? "Set by this deployment" : undefined;
|
|
728
|
+
const help = (_jsxs(_Fragment, { children: [definition.help && _jsx("span", { className: "text-xs text-text-muted", children: definition.help }), note] }));
|
|
681
729
|
if (definition.type === "boolean") {
|
|
682
730
|
// Laid out like the admin policy forms' checkboxes: the help sits under the label, beside the box.
|
|
683
731
|
return (_jsxs("label", { className: "flex items-start gap-2 text-sm", children: [_jsx("input", { type: "checkbox", className: "mt-1", checked: value === true, onChange: (e) => onChange(e.target.checked) }), _jsxs("span", { className: "flex flex-col gap-0.5", children: [_jsx("span", { className: "font-semibold", children: definition.label }), help] })] }));
|
|
684
732
|
}
|
|
685
|
-
return (_jsxs("label", { className: "flex flex-col gap-1.5 text-sm", children: [_jsx("span", { className: "font-semibold", children: definition.label }), definition.type === "select" ? (_jsxs("select", { "aria-label": definition.label, className: INPUT_CLASS, value: String(value), onChange: (e) => onChange(e.target.value), children: [!definition.required && _jsx("option", { value: "", children: "Default" }), (definition.options ?? []).map((option) => (_jsx("option", { value: option.value, children: option.label }, option.value)))] })) : (_jsx("input", { "aria-label": definition.label, className: INPUT_CLASS, type: definition.type === "number" ? "number" : "text", min: definition.min, max: definition.max, required: definition.required, value: String(value), onChange: (e) => onChange(e.target.value) })), help] }));
|
|
733
|
+
return (_jsxs("label", { className: "flex flex-col gap-1.5 text-sm", children: [_jsx("span", { className: "font-semibold", children: definition.label }), definition.type === "select" ? (_jsxs("select", { "aria-label": definition.label, className: INPUT_CLASS, value: String(value), onChange: (e) => onChange(e.target.value), children: [!definition.required && _jsx("option", { value: "", children: "Default" }), (definition.options ?? []).map((option) => (_jsx("option", { value: option.value, children: option.label }, option.value)))] })) : (_jsx("input", { "aria-label": definition.label, className: INPUT_CLASS, type: definition.type === "number" ? "number" : "text", min: definition.min, max: definition.max, required: definition.required && !configured, placeholder: placeholder, value: String(value), onChange: (e) => onChange(e.target.value) })), help] }));
|
|
686
734
|
}
|
|
687
735
|
function RemoveModal({ plugin, onClose, onRemoved }) {
|
|
688
736
|
const [error, setError] = useState(null);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rapidmx/web-client",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.19.0",
|
|
4
4
|
"description": "RapidMX's webmail and admin console React UI for web-based clients.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"repository": "https://github.com/rapidmx/web-client",
|
|
@@ -40,7 +40,7 @@
|
|
|
40
40
|
"@dnd-kit/core": "^6.3.1",
|
|
41
41
|
"@journeyapps/wa-sqlite": "^2.0.4",
|
|
42
42
|
"@peculiar/x509": "^2.1.0",
|
|
43
|
-
"@rapidmx/react-shared": "^0.
|
|
43
|
+
"@rapidmx/react-shared": "^0.20.0",
|
|
44
44
|
"@tiptap/core": "^3.31.3",
|
|
45
45
|
"@tiptap/extension-highlight": "^3.31.3",
|
|
46
46
|
"@tiptap/extension-image": "^3.31.3",
|