Move in block editor exclusive extension (#18525)

* move in block editor

* make lodash import consistent with core

* add translations

* add preview svg

* add changeset

* change interface id

* Update .changeset/ten-wasps-relate.md

* removed unneeded property definitions.

* address couple of warnings

* tweak checkbox color

* Check if file has been selected

* Update style overrides

* Override slightly blue background

* Add red background for delete confirmation

* Fix table add row and column button background color

* override checklist color when hovered

* override color of ripple effect for checkbox

* Fix config being undefined

* tweak popover style

* fix attaches tool

* Revert fix

* fix attaches styling

* Fix inline selection active color

* fix attaches download button

* tweak attaches file styling

* Fix alignment icons font colour

* remove nullable prop

Co-authored-by: ian <licitdev@gmail.com>

* tiny code style tweak

---------

Co-authored-by: Brainslug <tim@brainslug.nl>
Co-authored-by: Pascal Jufer <pascal-jufer@bluewin.ch>
Co-authored-by: Rijk van Zanten <rijkvanzanten@me.com>
Co-authored-by: ian <licitdev@gmail.com>
This commit is contained in:
Azri Kahar
2023-05-12 04:50:41 +08:00
committed by GitHub
parent 68da9f9b09
commit de290c31c3
12 changed files with 1616 additions and 0 deletions

View File

@@ -0,0 +1,5 @@
---
'@directus/app': minor
---
Added block editor interface

View File

@@ -37,6 +37,21 @@
"@directus/format-title": "10.0.0",
"@directus/types": "workspace:*",
"@directus/utils": "workspace:*",
"@editorjs/attaches": "1.3.0",
"@editorjs/checklist": "1.5.0",
"@editorjs/code": "2.8.0",
"@editorjs/delimiter": "1.3.0",
"@editorjs/editorjs": "2.26.5",
"@editorjs/embed": "2.5.3",
"@editorjs/header": "2.7.0",
"@editorjs/image": "2.8.1",
"@editorjs/inline-code": "1.4.0",
"@editorjs/nested-list": "1.3.0",
"@editorjs/paragraph": "2.9.0",
"@editorjs/quote": "2.5.0",
"@editorjs/raw": "2.4.0",
"@editorjs/table": "2.2.1",
"@editorjs/underline": "1.1.0",
"@fortawesome/fontawesome-svg-core": "6.4.0",
"@fortawesome/free-brands-svg-icons": "6.4.0",
"@fullcalendar/core": "6.1.5",
@@ -100,6 +115,8 @@
"diacritics": "1.3.0",
"diff": "5.1.0",
"dompurify": "3.0.2",
"editorjs-text-alignment-blocktune": "1.0.3",
"editorjs-toggle-block": "0.3.11",
"escape-string-regexp": "5.0.0",
"file-saver": "2.0.5",
"flatpickr": "4.6.13",

View File

@@ -0,0 +1,615 @@
.codex-editor {
--bg-color: var(--background-normal) !important;
--front-color: var(--foreground-normal) !important;
--border-color: var(--border-normal) !important;
}
.codex-editor .ce-toolbar__plus,
.codex-editor .ce-toolbar__settings-btn {
color: var(--foreground-normal);
background-color: var(--background-normal);
border-radius: var(--border-radius);
}
.codex-editor .ce-toolbar__plus:hover,
.codex-editor .ce-toolbar__settings-btn:hover {
background-color: var(--background-normal-alt);
}
.codex-editor .ce-toolbar__plus svg,
.codex-editor .ce-toolbar__settings-btn svg {
fill: var(--foreground-normal) !important;
}
.codex-editor .cdx-settings-button svg,
.codex-editor .ce-inline-toolbar__dropdown svg,
.codex-editor .ce-inline-toolbar .ce-inline-tool svg,
.codex-editor .ce-settings .ce-settings__button svg {
fill: var(--v-list-color) !important;
}
.codex-editor .ce-inline-toolbar .ce-inline-tool--active svg,
.codex-editor .ce-settings .cdx-settings-button--active svg {
fill: var(--primary) !important;
}
.codex-editor .cdx-settings-button--active {
color: var(--primary);
}
.codex-editor .ce-toolbar .ce-toolbox__button svg,
.codex-editor .ce-toolbar .ce-toolbox__button:hover svg {
fill: var(--foreground-normal);
}
.codex-editor .cdx-loader::before {
border: 3px solid var(--background-normal-alt);
border-left-color: var(--primary);
}
.codex-editor .cdx-search-field {
border-radius: var(--border-radius) !important;
border: none;
background: var(--background-subdued);
}
.codex-editor .ce-popover {
background: var(--background-page);
border-color: var(--border-normal);
border-width: var(--border-width);
border-radius: var(--border-radius);
}
.codex-editor .ce-popover__item--focused {
--webkit-box-shadow: none;
box-shadow: none;
background: var(--v-list-background-color-active) !important;
}
.codex-editor .ce-popover__item--confirmation .ce-popover__item-label {
color: var(--foreground-normal);
}
.codex-editor .ce-popover__item--active {
color: var(--primary);
background: var(--v-list-background-color-active);
}
.codex-editor .ce-popover__item:hover {
background: var(--v-list-background-color-hover) !important;
}
.codex-editor .ce-popover__item--confirmation,
.codex-editor .ce-popover__item--confirmation:hover {
background: var(--danger-alt) !important;
}
.codex-editor .ce-popover__item-icon {
background: var(--background-page);
border: var(--border-width) solid var(--border-normal);
box-shadow: none;
}
.codex-editor .ce-popover__item-icon svg {
fill: var(--foreground-normal);
}
.codex-editor .ce-popover__item-secondary-label {
color: var(--foreground-subdued);
}
.codex-editor .ce-popover__items:not(:first-child) {
border-top: var(--border-width) solid var(--border-normal);
padding-top: 8px;
}
.codex-editor .image-tool--loading .image-tool__image,
.codex-editor .ce-block--selected .ce-block__content {
background: var(--module-background-alt);
}
.codex-editor .image-tool--loading .image-tool__image {
border-color: var(--border-normal);
}
.codex-editor .ce-inline-toolbar__toggler-and-button-wrapper {
padding: 0;
}
.codex-editor .ce-inline-toolbar__dropdown {
margin: 0;
border-color: var(--border-normal);
}
.codex-editor .ce-inline-tool {
padding: 0 10px !important;
}
.codex-editor .ce-settings__button--confirm.ce-settings__button--confirm {
background-color: var(--danger-110) !important;
}
.codex-editor .ce-settings__button--confirm.ce-settings__button--confirm svg {
fill: var(--foreground-normal-alt) !important;
}
/* Tooltips */
.ct {
-webkit-box-shadow: none;
box-shadow: none;
}
.ct:before,
.ct:after {
background-color: var(--background-inverted);
}
.ct__content {
background-color: var(--background-inverted);
color: var(--foreground-inverted);
border-radius: 4px;
padding: 8px;
font-size: inherit;
}
/* Button balloons */
.codex-editor .ce-toolbox__button.ce-toolbox__button--active {
background: var(--background-normal-alt);
}
.codex-editor .ce-toolbox__button.ce-toolbox__button--active svg {
fill: var(--primary);
}
.codex-editor .ce-conversion-toolbar,
.codex-editor .ce-inline-toolbar,
.codex-editor .ce-settings {
background: var(--background-subdued);
border-color: var(--border-normal);
border-width: var(--border-width);
border-radius: var(--border-radius);
}
.codex-editor .cdx-settings-button:hover,
.codex-editor .ce-conversion-tool:hover,
.codex-editor .ce-inline-toolbar__dropdown:hover,
.codex-editor .ce-inline-toolbar .ce-inline-tool:hover,
.codex-editor .ce-settings .ce-settings__button:hover {
background: var(--background-normal-alt);
}
.codex-editor .ce-conversion-toolbar .ce-conversion-tool--focused:not(:hover) {
--webkit-box-shadow: unset;
color: var(--primary);
background-color: var(--v-list-item-background-color-hover) !important;
box-shadow: unset;
}
.codex-editor .ce-conversion-tool:hover {
--webkit-box-shadow: unset;
color: var(--v-list-item-color-hover);
background-color: var(--v-list-item-background-color-hover) !important;
box-shadow: unset;
}
.codex-editor .ce-conversion-toolbar__label {
color: var(--foreground-normal);
}
.codex-editor .ce-conversion-tool__icon {
background: transparent;
}
/* Textarea and inputs */
.codex-editor [contentEditable='true'][data-placeholder]::before {
font-weight: unset;
}
.codex-editor .ce-code__textarea {
font-family: var(--family-monospace);
}
.codex-editor .cdx-input,
.codex-editor .ce-code__textarea {
color: var(--foreground-normal);
font-size: inherit;
background-color: var(--background-page);
border: var(--border-width) solid var(--border-normal);
border-radius: var(--border-radius);
box-shadow: unset;
}
.codex-editor .cdx-input:hover,
.codex-editor .ce-code__textarea:hover {
border-color: var(--border-normal-alt);
}
.codex-editor .cdx-input:focus,
.codex-editor .ce-code__textarea:focus {
border-color: var(--primary);
}
.codex-editor .ce-inline-tool-input {
color: var(--primary);
background: var(--background-normal);
border-top-color: var(--border-normal);
}
/* Buttons */
.codex-editor .cdx-button {
color: var(--v-button-color);
font-weight: var(--v-button-font-weight);
font-size: var(--v-button-font-size);
text-decoration: none;
background-color: var(--v-button-background-color);
border: var(--border-width) solid var(--v-button-background-color);
border-radius: var(--border-radius);
cursor: pointer;
}
.codex-editor .cdx-button:hover {
color: var(--v-button-color-hover);
background-color: var(--v-button-background-color-hover);
border-color: var(--v-button-background-color-hover);
}
/* General components re-style */
.codex-editor .cdx-attaches,
.codex-editor .cdx-personality {
color: var(--foreground-normal);
background: var(--background-normal);
border-color: var(--border-normal);
box-shadow: unset;
}
/* Paragraph */
.codex-editor .ce-paragraph {
padding: 0;
line-height: inherit;
}
.codex-editor .ce-paragraph[data-placeholder]:empty::before {
color: var(--foreground-subdued);
}
/* Quote block */
.codex-editor .cdx-block.cdx-quote {
padding-left: 22px;
}
/* Image Tool */
.codex-editor .image-tool {
--bg-color: var(--background-normal-alt);
--front-color: var(--primary);
--border-color: var(--border-normal);
}
/* Personality */
.codex-editor .cdx-personality__description {
font-weight: unset;
font-size: 0.9em;
font-style: oblique;
}
.codex-editor .cdx-personality .cdx-personality__photo {
background-color: transparent;
}
.codex-editor .cdx-personality__link {
color: var(--primary);
font-size: 0.72em;
}
/* Checklist */
.codex-editor .cdx-checklist__item-checkbox {
background: transparent;
border-color: var(--border-normal);
border-width: var(--border-width);
border-radius: 4px;
}
.codex-editor .cdx-checklist__item-text {
padding: 3px 0;
}
/* Ripple effect when enabling a checkbox */
.codex-editor .cdx-checklist__item-checkbox .cdx-checklist__item-checkbox-check::before {
background-color: var(--primary);
}
.codex-editor .cdx-checklist__item--checked .cdx-checklist__item-checkbox .cdx-checklist__item-checkbox-check {
background: var(--primary);
border-color: var(--primary);
}
/* Checkbox color when hovered. We have to use !important here because the specificity of the default styling is higher */
.codex-editor .cdx-checklist__item--checked .cdx-checklist__item-checkbox:hover .cdx-checklist__item-checkbox-check {
background: var(--primary-125) !important;
border-color: var(--primary-125) !important;
}
.codex-editor .cdx-checklist__item--checked .cdx-checklist__item-checkbox::after {
top: 4px;
left: 3px;
}
/* Lists */
.codex-editor .ce-block ul,
.codex-editor .ce-block ol {
font-size: inherit !important;
}
/* Attaches */
.codex-editor .cdx-attaches--with-file {
border: var(--border-width) solid var(--border-normal);
}
.codex-editor .cdx-attaches--with-file .cdx-attaches__file-icon {
padding: 5px;
background: var(--background-normal-alt);
border-radius: var(--border-radius);
margin-right: 12px;
}
.codex-editor .cdx-attaches--with-file .cdx-attaches__file-icon::before {
background: transparent;
}
.codex-editor .cdx-attaches--with-file .cdx-attaches__file-icon-background {
margin-right: 0;
}
.codex-editor .cdx-attaches--with-file .cdx-attaches__title {
color: var(--foreground-normal);
}
.codex-editor .cdx-attaches--with-file .cdx-attaches__download-button {
background: var(--background-subdued);
border: var(--border-width) solid var(--border-normal);
}
.codex-editor .cdx-attaches--with-file .cdx-attaches__download-button:hover {
background: var(--background-highlight);
}
.codex-editor .cdx-attaches--with-file .cdx-attaches__download-button svg {
fill: var(--v-button-background-color);
}
.codex-editor .cdx-attaches__size {
color: var(--foreground-subdued);
}
/* InlineCode */
.codex-editor .ce-block .inline-code {
padding: 4px;
color: var(--v-chip-background-color);
font-family: monospace;
background-color: var(--v-chip-color);
border-radius: 4px;
}
/* tc module */
.codex-editor .tc-wrap {
--color-background: var(--v-list-item-background-color-active);
--color-border: var(--border-normal);
--color-background-hover: var(--v-list-item-background-color-hover);
}
.codex-editor .tc-popover {
--color-background: var(--background-normal);
--color-border: var(--background-normal);
--color-background-hover: var(--background-normal-alt);
}
.tc-cell--selected,
.tc-cell--selected::after,
.tc-row--selected,
.tc-row--selected::after {
background: var(--background-highlight);
}
.tc-toolbox {
--toggler-dots-color: var(--foreground-normal);
--toggler-dots-color-hovered: var(--foreground-normal-alt);
}
.tc-toolbox__toggler:hover {
fill: var(--background-normal-alt) !important;
}
.codex-editor .tc-toolbox__toggler svg rect:first-child {
/* This is very ugly, but there no other way to set background of the element */
fill: var(--background-normal) !important;
border-radius: var(--border-radius);
}
.codex-editor .tc-toolbox__toggler:hover svg rect:first-child {
/* This is very ugly, but there no other way to set background of the element */
fill: var(--background-normal-alt) !important;
}
.tc-add-column:hover,
.tc-add-row:hover {
background-color: var(--primary-alt);
}
.tc-add-row:hover::before {
background-color: var(--v-list-item-background-color-hover);
}
.interface.subdued .codex-editor {
pointer-events: none !important;
}
.ce-toolbar__content,
.ce-block__content {
max-width: unset;
margin-right: 34px;
margin-left: 34px;
}
.codex-editor .ce-header {
padding: 0.4em 0;
}
.codex-editor .ce-block {
margin-top: 1.5em;
margin-bottom: 1.5em;
font-weight: inherit;
font-family: inherit;
}
.codex-editor .ce-block h1 {
font-weight: 300;
font-size: 44px;
line-height: 52px;
}
.codex-editor .ce-block h2 {
font-weight: 600;
font-size: 34px;
line-height: 38px;
}
.codex-editor .ce-block h3 {
font-weight: 600;
font-size: 26px;
line-height: 31px;
}
.codex-editor .ce-block h4 {
font-weight: 600;
font-size: 22px;
line-height: 28px;
}
.codex-editor .ce-block h5 {
font-weight: 600;
font-size: 18px;
line-height: 26px;
}
.codex-editor .ce-block h6 {
font-weight: 600;
font-size: 16px;
line-height: 24px;
}
.codex-editor .ce-block p {
margin-top: 20px;
margin-bottom: 20px;
font-size: 16px;
font-family: inherit;
line-height: 32px;
}
.codex-editor .ce-block a {
color: var(--primary);
}
.codex-editor .ce-block a.btn {
color: #fff !important;
}
.codex-editor .ce-block ul,
.codex-editor .ce-block ol {
margin: 24px 0;
font-size: 18px;
line-height: 34px;
}
.codex-editor .ce-block ul ul,
.codex-editor .ce-block ol ol,
.codex-editor .ce-block ul ol,
.codex-editor .ce-block ol ul {
margin: 0;
}
.codex-editor .ce-block b,
.codex-editor .ce-block strong {
color: var(--foreground-normal-alt);
font-weight: 600;
}
.codex-editor .ce-block pre {
padding: 20px;
overflow: auto;
font-size: 18px;
line-height: 24px;
background-color: var(--background-normal-alt);
border-radius: 4px;
}
.codex-editor .ce-block blockquote {
font-size: 18px;
font-style: italic;
line-height: 34px;
border-left: 2px dashed var(--border-normal);
}
.codex-editor .ce-block video,
.codex-editor .ce-block iframe,
.codex-editor .ce-block img {
max-width: 100%;
height: auto;
border-radius: 4px;
}
.codex-editor .ce-block hr {
margin-top: 52px;
margin-bottom: 56px;
text-align: center;
border: 0;
}
.codex-editor .ce-block hr::after {
font-size: 28px;
line-height: 0;
letter-spacing: 16px;
content: '...';
}
.codex-editor .ce-block table {
border-collapse: collapse;
}
.codex-editor .ce-block table th,
.codex-editor .ce-block table td {
border: 1px solid var(--border-normal);
}
.codex-editor .ce-block .tc-table__wrap,
.codex-editor .ce-block .tc-table__cell {
border-color: var(--border-normal);
}
.codex-editor .ce-block figure {
display: table;
margin: 1rem auto;
}
.codex-editor .ce-block figure figcaption {
display: block;
margin-top: 0.25rem;
color: var(--foreground-subdued);
text-align: center;
}
.codex-editor .ce-inline-tool--active {
color: var(--primary);
}

View File

@@ -0,0 +1,163 @@
import { defineInterface } from '@directus/utils';
import InterfaceBlockEditor from './input-block-editor.vue';
import PreviewSVG from './preview.svg?raw';
export default defineInterface({
id: 'input-block-editor',
name: '$t:interfaces.input-block-editor.input-block-editor',
description: '$t:interfaces.input-block-editor.description',
icon: 'code',
component: InterfaceBlockEditor,
types: ['json'],
group: 'standard',
preview: PreviewSVG,
options: [
{
field: 'placeholder',
name: '$t:placeholder',
meta: {
width: 'half',
interface: 'text-input',
options: {
placeholder: '$t:enter_a_placeholder',
},
},
},
{
field: 'font',
name: '$t:font',
type: 'string',
meta: {
width: 'half',
interface: 'select-dropdown',
options: {
choices: [
{
text: '$t:sans_serif',
value: 'sans-serif',
},
{
text: '$t:monospace',
value: 'monospace',
},
{
text: '$t:serif',
value: 'serif',
},
],
},
},
schema: {
default_value: 'sans-serif',
},
},
{
field: 'tools',
name: '$t:interfaces.input-block-editor.tools',
type: 'json',
schema: {
default_value: ['header', 'nestedlist', 'code', 'image', 'paragraph', 'checklist', 'quote', 'underline'],
},
meta: {
width: 'half',
interface: 'select-multiple-dropdown',
options: {
choices: [
{
value: 'header',
text: '$t:interfaces.input-block-editor.tools_options.header',
},
{
value: 'nestedlist',
text: '$t:interfaces.input-block-editor.tools_options.nestedlist',
},
{
value: 'embed',
text: '$t:interfaces.input-block-editor.tools_options.embed',
},
{
value: 'paragraph',
text: '$t:interfaces.input-block-editor.tools_options.paragraph',
},
{
value: 'code',
text: '$t:interfaces.input-block-editor.tools_options.code',
},
{
value: 'image',
text: '$t:interfaces.input-block-editor.tools_options.image',
},
{
value: 'attaches',
text: '$t:interfaces.input-block-editor.tools_options.attaches',
},
{
value: 'table',
text: '$t:interfaces.input-block-editor.tools_options.table',
},
{
value: 'quote',
text: '$t:interfaces.input-block-editor.tools_options.quote',
},
{
value: 'underline',
text: '$t:interfaces.input-block-editor.tools_options.underline',
},
{
value: 'inlinecode',
text: '$t:interfaces.input-block-editor.tools_options.inlinecode',
},
{
value: 'delimiter',
text: '$t:interfaces.input-block-editor.tools_options.delimiter',
},
{
value: 'checklist',
text: '$t:interfaces.input-block-editor.tools_options.checklist',
},
{
value: 'toggle',
text: '$t:interfaces.input-block-editor.tools_options.toggle',
},
{
value: 'alignment',
text: '$t:interfaces.input-block-editor.tools_options.alignment',
},
{
value: 'raw',
text: '$t:interfaces.input-block-editor.tools_options.raw',
},
],
},
},
},
{
field: 'bordered',
name: '$t:displays.formatted-value.border',
type: 'boolean',
meta: {
width: 'half',
interface: 'boolean',
options: {
label: '$t:displays.formatted-value.border_label',
},
},
schema: {
default_value: true,
},
},
{
field: 'folder',
name: '$t:interfaces.system-folder.folder',
type: 'uuid',
meta: {
width: 'full',
interface: 'system-folder',
note: '$t:interfaces.system-folder.field_hint',
},
schema: {
default_value: undefined,
},
},
],
});

View File

@@ -0,0 +1,250 @@
<template>
<div class="input-block-editor">
<div ref="editorElement" :class="{ [font]: true, disabled, bordered }"></div>
<v-drawer
v-if="haveFilesAccess && !disabled"
:model-value="fileHandler !== null"
icon="image"
:title="t('upload_from_device')"
:cancelable="true"
@update:model-value="unsetFileHandler"
@cancel="unsetFileHandler"
>
<div class="uploader-drawer-content">
<div v-if="currentPreview" class="uploader-preview-image">
<img :src="currentPreview" />
</div>
<v-upload
:ref="uploaderComponentElement"
:multiple="false"
:folder="folder"
from-library
from-url
@input="handleFile"
/>
</div>
</v-drawer>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, onUnmounted, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import api, { addTokenToURL } from '@/api';
import EditorJS from '@editorjs/editorjs';
import { isEqual, cloneDeep } from 'lodash';
import { useFileHandler } from './use-file-handler';
import getTools from './tools';
import { useCollectionsStore } from '@/stores/collections';
import { unexpectedError } from '@/utils/unexpected-error';
const props = withDefaults(
defineProps<{
disabled?: boolean;
autofocus?: boolean;
value?: Record<string, any> | null;
bordered?: boolean;
placeholder?: string;
tools?: string[];
folder?: string;
font?: 'sans-serif' | 'monospace' | 'serif';
}>(),
{
disabled: false,
autofocus: false,
value: () => null,
bordered: true,
tools: () => ['header', 'nestedlist', 'code', 'image', 'paragraph', 'checklist', 'quote', 'underline'],
font: 'sans-serif',
}
);
const emit = defineEmits(['input']);
const { t } = useI18n();
const collectionStore = useCollectionsStore();
const { currentPreview, setCurrentPreview, fileHandler, setFileHandler, unsetFileHandler, handleFile } =
useFileHandler();
const editorjsRef = ref<EditorJS>();
const uploaderComponentElement = ref<HTMLElement>();
const editorElement = ref<HTMLElement>();
const haveFilesAccess = Boolean(collectionStore.getCollection('directus_files'));
const haveValuesChanged = ref<boolean>(false);
const tools = getTools(
{
addTokenToURL,
baseURL: api.defaults.baseURL,
setFileHandler,
setCurrentPreview,
getUploadFieldElement: () => uploaderComponentElement,
},
props.tools,
haveFilesAccess
);
onMounted(async () => {
const sanitizedValue = sanitizeValue(props.value);
editorjsRef.value = new EditorJS({
logLevel: 'ERROR' as EditorJS.LogLevels,
holder: editorElement.value,
readOnly: false,
placeholder: props.placeholder,
minHeight: 72,
onChange: (api: any, event: any) => emitValue(api, event),
tools: tools,
});
// we have initial data, so we render it once the editor is ready...
await editorjsRef.value.isReady;
if (sanitizedValue) {
await editorjsRef.value.render(sanitizedValue);
}
if (props.autofocus) {
editorjsRef.value.focus();
}
});
onUnmounted(() => {
if (!editorjsRef.value) return;
editorjsRef.value.destroy();
});
watch(
() => props.value,
async (newVal: any, oldVal: any) => {
if (!editorjsRef.value || !editorjsRef.value.isReady || haveValuesChanged.value) return;
if (fileHandler.value !== null) return;
if (isEqual(newVal?.blocks, oldVal?.blocks)) return;
try {
await editorjsRef.value.isReady;
const sanitizedValue = sanitizeValue(newVal);
if (sanitizedValue) {
await editorjsRef.value.render(sanitizedValue);
} else {
editorjsRef.value.clear();
}
} catch (err: any) {
unexpectedError(err);
}
haveValuesChanged.value = false;
}
);
async function emitValue(context: EditorJS.API, _event: CustomEvent) {
if (props.disabled || !context || !context.saver) return;
haveValuesChanged.value = true;
try {
const result: EditorJS.OutputData = await context.saver.save();
if (!result || result.blocks.length < 1) {
emit('input', null);
return;
}
if (isEqual(result.blocks, props.value?.blocks)) return;
emit('input', result);
} catch (err: any) {
unexpectedError(err);
}
}
function sanitizeValue(value: any): EditorJS.OutputData | null {
if (!value || typeof value !== 'object' || !value.blocks || value.blocks.length < 1) return null;
// we use cloneDeep to recursively clone the object
return cloneDeep({
time: value?.time || Date.now(),
version: value?.version || '0.0.0',
blocks: value.blocks,
});
}
</script>
<style lang="scss">
@import './editorjs-overrides.css';
</style>
<style lang="scss" scoped>
.btn--default {
color: #fff !important;
background-color: #0d6efd;
border-color: #0d6efd;
}
.btn--gray {
color: #fff !important;
background-color: #7c7c7c;
border-color: #7c7c7c;
}
.disabled {
color: var(--foreground-subdued);
background-color: var(--background-subdued);
border-color: var(--border-normal);
pointer-events: none;
}
.bordered {
padding: var(--input-padding) 4px var(--input-padding) calc(var(--input-padding) + 8px) !important;
background-color: var(--background-page);
border: var(--border-width) solid var(--border-normal);
border-radius: var(--border-radius);
&:hover {
border-color: var(--border-normal-alt);
}
&:focus-within {
border-color: var(--primary);
}
}
.monospace {
font-family: var(--family-monospace);
}
.serif {
font-family: var(--family-serif);
}
.sans-serif {
font-family: var(--family-sans-serif);
}
.uploader-drawer-content {
padding: var(--content-padding);
padding-top: 0;
padding-bottom: var(--content-padding);
}
.uploader-preview-image {
margin-bottom: var(--form-vertical-gap);
background-color: var(--background-normal);
border-radius: var(--border-radius);
}
.uploader-preview-image img {
display: block;
width: auto;
max-width: 100%;
height: auto;
max-height: 40vh;
margin: 0 auto;
object-fit: contain;
}
</style>

View File

@@ -0,0 +1,160 @@
import BaseAttachesTool from '@editorjs/attaches';
import BaseImageTool from '@editorjs/image';
import { unexpectedError } from '@/utils/unexpected-error';
/**
* This file is a modified version of the attaches and image tool from editorjs to work with the Directus file manager.
*
* We include an uploader to directly use Directus file manager, along with a modified version of the attaches and image tools.
*/
class Uploader {
getCurrentFile: any;
config: any;
onUpload: any;
onError: any;
constructor({
config,
getCurrentFile,
onUpload,
onError,
}: {
config: any;
getCurrentFile?: any;
onUpload: any;
onError: any;
}) {
this.getCurrentFile = getCurrentFile;
this.config = config;
this.onUpload = onUpload;
this.onError = onError;
}
async uploadByFile(file: any, { onPreview }: any) {
try {
await Promise.all([this.uploadSelectedFile({ onPreview }), onPreview()]);
if (!this.config.uploader.getUploadFieldElement) return;
this.config.uploader.getUploadFieldElement().onBrowseSelect({
target: {
files: [file],
},
});
} catch (err: any) {
unexpectedError(err);
}
}
uploadByUrl(url: string) {
this.onUpload({
success: 1,
file: {
url: url,
},
});
}
uploadSelectedFile({ onPreview }: { onPreview: any }) {
if (this.getCurrentFile) {
const currentPreview = this.getCurrentFile();
if (currentPreview) {
this.config.uploader.setCurrentPreview(
this.config.uploader.addTokenToURL(currentPreview) + '&key=system-large-contain'
);
}
}
this.config.uploader.setFileHandler(
(file: { width: any; height: any; filesize: any; filename_download: string; title: any; id: string }) => {
if (!file) {
this.onError({
success: 0,
message: this.config.t.no_file_selected,
});
return;
}
const response = {
success: 1,
file: {
width: file.width,
height: file.height,
size: file.filesize,
name: file.filename_download,
title: file.title,
extension: file.filename_download.split('.').pop(),
fileId: file.id,
fileURL: this.config.uploader.baseURL + 'files/' + file.id,
url: this.config.uploader.baseURL + 'assets/' + file.id,
},
};
onPreview(this.config.uploader.addTokenToURL(response.file.fileURL));
this.onUpload(response);
}
);
}
}
export class AttachesTool extends BaseAttachesTool {
constructor(params: {
config: { uploader: any };
block: { save: () => Promise<any> };
api: { blocks: { update: (arg0: any, arg1: any) => void } };
}) {
super(params);
this.config.uploader = params.config.uploader;
this.uploader = new Uploader({
config: this.config,
onUpload: (response: any) => this.onUpload(response),
onError: (error: any) => this.uploadingFailed(error),
});
this.onUpload = (response: any) => {
super.onUpload(response);
params.block.save().then((state) => {
params.api.blocks.update(state.id, state.data);
});
};
}
showFileData() {
super.showFileData();
if (this.data.file && this.data.file.url) {
const downloadButton = this.nodes.wrapper.querySelector('a.cdx-attaches__download-button');
if (downloadButton) {
downloadButton.href = this.config.uploader.addTokenToURL(this.data.file.url) + '&download';
}
}
}
}
export class ImageTool extends BaseImageTool {
constructor(params: any) {
super(params);
this.uploader = new Uploader({
config: this.config,
getCurrentFile: () => this.data?.file?.url,
onUpload: (response: any) => this.onUpload(response),
onError: (error: any) => this.uploadingFailed(error),
});
}
set image(file: { url?: any }) {
this._data.file = file || {};
if (file && file.url) {
const imageUrl = this.config.uploader.addTokenToURL(file.url) + '&key=system-large-contain';
this.ui.fillImage(imageUrl);
}
}
}

View File

@@ -0,0 +1,14 @@
<svg width="156" height="96" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="18" y="15" width="120" height="66" rx="6" fill="var(--background-page)" class="glow" />
<rect x="19" y="16" width="118" height="64" rx="5" stroke="var(--primary)" stroke-width="2" />
<rect x="28" y="25" width="6" height="6" rx="2" fill="var(--primary)" fill-opacity=".25" />
<rect x="28" y="45.148" width="6" height="6" rx="2" fill="var(--primary)" fill-opacity=".25" />
<rect x="28" y="55.222" width="6" height="6" rx="2" fill="var(--primary)" fill-opacity=".25" />
<rect x="28" y="65.296" width="6" height="6" rx="2" fill="var(--primary)" fill-opacity=".25" />
<rect x="28" y="35.074" width="6" height="6" rx="2" fill="var(--primary)" fill-opacity=".25" />
<rect x="42" y="25" width="10" height="6" rx="2" fill="var(--primary)" />
<rect x="46" y="35" width="50" height="6" rx="2" fill="var(--primary)" fill-opacity=".25" />
<rect x="46" y="45" width="60" height="6" rx="2" fill="var(--primary)" fill-opacity=".25" />
<rect x="46" y="55" width="40" height="6" rx="2" fill="var(--primary)" fill-opacity=".25" />
<rect x="42" y="65" width="10" height="6" rx="2" fill="var(--primary)" />
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

View File

@@ -0,0 +1,127 @@
import ChecklistTool from '@editorjs/checklist';
import CodeTool from '@editorjs/code';
import DelimiterTool from '@editorjs/delimiter';
import EmbedTool from '@editorjs/embed';
import HeaderTool from '@editorjs/header';
import InlineCodeTool from '@editorjs/inline-code';
import NestedListTool from '@editorjs/nested-list';
import ParagraphTool from '@editorjs/paragraph';
import QuoteTool from '@editorjs/quote';
import RawToolTool from '@editorjs/raw';
import TableTool from '@editorjs/table';
import UnderlineTool from '@editorjs/underline';
import AlignmentTuneTool from 'editorjs-text-alignment-blocktune';
import ToggleBlock from 'editorjs-toggle-block';
import { AttachesTool, ImageTool } from './plugins';
export type UploaderConfig = {
addTokenToURL: (url: string, token: string) => string;
baseURL: string | undefined;
setFileHandler: (handler: any) => void;
setCurrentPreview?: (url: string) => void;
getUploadFieldElement: () => any;
};
export default function getTools(
uploaderConfig: UploaderConfig,
selection: Array<string>,
haveFilesAccess: boolean
): Record<string, object> {
const tools: Record<string, any> = {};
const fileRequiresTools = ['attaches', 'image'];
const defaults: Record<string, any> = {
header: {
class: HeaderTool,
inlineToolbar: true,
},
list: {
class: NestedListTool,
inlineToolbar: false,
},
nestedlist: {
class: NestedListTool,
inlineToolbar: true,
},
embed: {
class: EmbedTool,
inlineToolbar: true,
},
paragraph: {
class: ParagraphTool,
inlineToolbar: true,
},
code: {
class: CodeTool,
},
underline: {
class: UnderlineTool,
},
table: {
class: TableTool,
inlineToolbar: true,
},
quote: {
class: QuoteTool,
inlineToolbar: true,
},
inlinecode: {
class: InlineCodeTool,
},
delimiter: {
class: DelimiterTool,
},
raw: {
class: RawToolTool,
},
checklist: {
class: ChecklistTool,
inlineToolbar: true,
},
image: {
class: ImageTool,
config: {
uploader: uploaderConfig,
},
},
attaches: {
class: AttachesTool,
config: {
uploader: uploaderConfig,
},
},
toggle: {
class: ToggleBlock,
inlineToolbar: true,
},
alignment: {
class: AlignmentTuneTool,
},
};
for (const toolName of selection) {
if (!haveFilesAccess && fileRequiresTools.includes(toolName)) continue;
if (toolName in defaults) {
tools[toolName] = defaults[toolName];
}
}
// Add alignment to all tools that support it if it's enabled.
// editor.js tools means we need to already declare alignment in the tools object before we can assign it to other tools.
if ('alignment' in tools) {
if ('paragraph' in tools) {
tools['paragraph'].tunes = ['alignment'];
}
if ('header' in tools) {
tools['header'].tunes = ['alignment'];
}
if ('quote' in tools) {
tools['quote'].tunes = ['alignment'];
}
}
return tools;
}

View File

@@ -0,0 +1,38 @@
import { ref } from 'vue';
type UploaderHandler = (file: Record<string, any>) => void;
export function useFileHandler() {
const fileHandler = ref<UploaderHandler | null>(null);
const currentPreview = ref<string | null>(null);
return {
fileHandler,
unsetFileHandler,
setFileHandler,
handleFile,
currentPreview,
setCurrentPreview,
};
function setCurrentPreview(url: string | undefined | null) {
currentPreview.value = url || null;
}
function unsetFileHandler() {
fileHandler.value = null;
currentPreview.value = null;
}
function setFileHandler(handler: UploaderHandler) {
fileHandler.value = handler;
}
function handleFile(event: InputEvent | null) {
if (fileHandler.value && event) {
fileHandler.value(event);
}
unsetFileHandler();
}
}

View File

@@ -1764,6 +1764,27 @@ interfaces:
folder_note: Folder for uploaded files. Does not affect existing files.
imageToken: Static Access Token
imageToken_label: Static access token is appended to the assets' URL
input-block-editor:
input-block-editor: Block Editor
description: Block-styled editor for rich media stories, outputs clean data in JSON using Editor.js
tools: Toolbar
tools_options:
header: Header
nestedlist: List
embed: Embed
paragraph: Paragraph
code: Code
image: Image
attaches: Attaches
table: Table
quote: Quote
underline: Underline
inlinecode: Inline Code
delimiter: Delimiter
checklist: Checklist
toggle: Toggle Block
alignment: Alignment
raw: Raw HTML
input-autocomplete-api:
input-autocomplete-api: Autocomplete Input (API)
description: A search typeahead for external API values.

17
app/src/shims.d.ts vendored
View File

@@ -34,3 +34,20 @@ declare module 'frappe-charts/src/js/charts/AxisChart' {
}
declare module '@directus-extensions' {}
declare module '@editorjs/image';
declare module '@editorjs/attaches';
declare module '@editorjs/paragraph';
declare module '@editorjs/quote';
declare module '@editorjs/checklist';
declare module '@editorjs/delimiter';
declare module '@editorjs/table';
declare module '@editorjs/code';
declare module '@editorjs/header';
declare module '@editorjs/underline';
declare module '@editorjs/embed';
declare module '@editorjs/raw';
declare module '@editorjs/inline-code';
declare module '@editorjs/nested-list';
declare module 'editorjs-text-alignment-blocktune';
declare module 'editorjs-toggle-block';

189
pnpm-lock.yaml generated
View File

@@ -517,6 +517,51 @@ importers:
'@directus/utils':
specifier: workspace:*
version: link:../packages/utils
'@editorjs/attaches':
specifier: 1.3.0
version: 1.3.0
'@editorjs/checklist':
specifier: 1.5.0
version: 1.5.0
'@editorjs/code':
specifier: 2.8.0
version: 2.8.0
'@editorjs/delimiter':
specifier: 1.3.0
version: 1.3.0
'@editorjs/editorjs':
specifier: 2.26.5
version: 2.26.5
'@editorjs/embed':
specifier: 2.5.3
version: 2.5.3
'@editorjs/header':
specifier: 2.7.0
version: 2.7.0
'@editorjs/image':
specifier: 2.8.1
version: 2.8.1
'@editorjs/inline-code':
specifier: 1.4.0
version: 1.4.0
'@editorjs/nested-list':
specifier: 1.3.0
version: 1.3.0
'@editorjs/paragraph':
specifier: 2.9.0
version: 2.9.0
'@editorjs/quote':
specifier: 2.5.0
version: 2.5.0
'@editorjs/raw':
specifier: 2.4.0
version: 2.4.0
'@editorjs/table':
specifier: 2.2.1
version: 2.2.1
'@editorjs/underline':
specifier: 1.1.0
version: 1.1.0
'@fortawesome/fontawesome-svg-core':
specifier: 6.4.0
version: 6.4.0
@@ -706,6 +751,12 @@ importers:
dompurify:
specifier: 3.0.2
version: 3.0.2
editorjs-text-alignment-blocktune:
specifier: 1.0.3
version: 1.0.3
editorjs-toggle-block:
specifier: 0.3.11
version: 0.3.11
escape-string-regexp:
specifier: 5.0.0
version: 5.0.0
@@ -4355,6 +4406,30 @@ packages:
prettier: 2.8.7
dev: true
/@codexteam/icons@0.0.2:
resolution: {integrity: sha512-KdeKj3TwaTHqM3IXd5YjeJP39PBUZTb+dtHjGlf5+b0VgsxYD4qzsZkb11lzopZbAuDsHaZJmAYQ8LFligIT6Q==}
dev: true
/@codexteam/icons@0.0.4:
resolution: {integrity: sha512-V8N/TY2TGyas4wLrPIFq7bcow68b3gu8DfDt1+rrHPtXxcexadKauRJL6eQgfG7Z0LCrN4boLRawR4S9gjIh/Q==}
dev: true
/@codexteam/icons@0.0.5:
resolution: {integrity: sha512-s6H2KXhLz2rgbMZSkRm8dsMJvyUNZsEjxobBEg9ztdrb1B2H3pEzY6iTwI4XUPJWJ3c3qRKwV4TrO3J5jUdoQA==}
dev: true
/@codexteam/icons@0.0.6:
resolution: {integrity: sha512-L7Q5PET8PjKcBT5wp7VR+FCjwCi5PUp7rd/XjsgQ0CI5FJz0DphyHGRILMuDUdCW2MQT9NHbVr4QP31vwAkS/A==}
dev: true
/@codexteam/icons@0.1.0:
resolution: {integrity: sha512-jW1fWnwtWzcP4FBGsaodbJY3s1ZaRU+IJy1pvJ7ygNQxkQinybJcwXoyt0a5mWwu/4w30A42EWhCrZn8lp4fdw==}
dev: true
/@codexteam/icons@0.3.0:
resolution: {integrity: sha512-fJE9dfFdgq8xU+sbsxjH0Kt8Yeatw9xHBJWb77DhRkEXz3OCoIS6hrRC1ewHEryxzIjxD8IyQrRq2f+Gz3BcmA==}
dev: true
/@colors/colors@1.5.0:
resolution: {integrity: sha512-ooWCrlZP11i8GImSjTHYHLkvFDP48nS4+204nGb1RiX/WXYHmJA2III9/e2DWVabCESdW7hBAEzHRqUn9OUVvQ==}
engines: {node: '>=0.1.90'}
@@ -4413,6 +4488,98 @@ packages:
- '@algolia/client-search'
dev: true
/@editorjs/attaches@1.3.0:
resolution: {integrity: sha512-D+dj55EsC5bvXV///Dh/+KN4unuI0j6SU3f5QcMA5zrfda47PNKwbKOuwMBJwn0O+o5qwqrFv67g98lAo2qqVQ==}
dependencies:
'@codexteam/icons': 0.0.4
dev: true
/@editorjs/checklist@1.5.0:
resolution: {integrity: sha512-2QUKDvLmtOFDaQLX6C+D9A4hIGBhsYwZW+60fOghSwC80dD931zOAA71cf+xppCTEhcr4eT18C6BJkBGf/DjTw==}
dependencies:
'@codexteam/icons': 0.3.0
dev: true
/@editorjs/code@2.8.0:
resolution: {integrity: sha512-qlv1OqSEPKnLv/ZQgNFmVgqsMbGDkh/qgTEnbVWsS+yujo1nlwgVkqCB8tOkQGVsrpmAYLiWRlA413nKxxCN5w==}
dependencies:
'@codexteam/icons': 0.0.5
dev: true
/@editorjs/delimiter@1.3.0:
resolution: {integrity: sha512-/qz+3yRSPmx6dJpBcwnDfgKtCjOrCsQRoDhXR+wLWirGOchOifhYwQ/6JZyTvcgmY84UEoYY29/dgdWOGVPEEQ==}
dependencies:
'@codexteam/icons': 0.0.5
dev: true
/@editorjs/editorjs@2.26.5:
resolution: {integrity: sha512-imwXZi9NmzxKjNosa1xQf286liJYsTe2J2DWCiV5TwKhvYZ1INg5Y+FietcM2v65QmeLqP7wgBUhoI7wiCB+yQ==}
dependencies:
'@codexteam/icons': 0.1.0
codex-notifier: 1.1.2
codex-tooltip: 1.0.5
html-janitor: 2.0.4
nanoid: 3.3.6
dev: true
/@editorjs/embed@2.5.3:
resolution: {integrity: sha512-+X0xX2tiwQjU/B2rPDv8DOwg0suiqrt2h/o9frjR308YuY1VTpqjLRF1lIV4TmelQyVr9cXkZruaa4Ty1wvfJg==}
dev: true
/@editorjs/header@2.7.0:
resolution: {integrity: sha512-4fGKGe2ZYblVqR/P/iw5ieG00uXInFgNMftBMqJRYcB2hUPD30kuu7Sn6eJDcLXoKUMOeqi8Z2AlUxYAmvw7zQ==}
dependencies:
'@codexteam/icons': 0.0.5
dev: true
/@editorjs/image@2.8.1:
resolution: {integrity: sha512-4WscDAoi6OO0F6L7N1mkQymADwj8hHgH/ICk5wGRPdkesUZW1TgldX8XvSmy+f5VylsEi3F/gUggaZsrYxu2sA==}
dependencies:
'@codexteam/icons': 0.0.6
dev: true
/@editorjs/inline-code@1.4.0:
resolution: {integrity: sha512-nJJx2eBgQyml7U8MdMdJNFY2RgZCOuvvXHEW73xsdu36ZXCd44eAo7vq1S5Jz9l8bC676SvNbRfeH/nojXK37A==}
dependencies:
'@codexteam/icons': 0.0.5
dev: true
/@editorjs/nested-list@1.3.0:
resolution: {integrity: sha512-diRsa64YjR4Xnf5jbSxnRn9kfFc0ZOQBqp0dx3ZwHkm5wxN9D46KI7qXdIyOoUNzAFyK+/Q5c29PcoBJLOp01w==}
dependencies:
'@codexteam/icons': 0.0.2
dev: true
/@editorjs/paragraph@2.9.0:
resolution: {integrity: sha512-lI6x1eiqFx2X3KmMak6gBlimFqXhG35fQpXMxzrjIphNLr4uPsXhVbyMPimPoLUnS9rM6Q00vptXmP2QNDd0zg==}
dependencies:
'@codexteam/icons': 0.0.4
dev: true
/@editorjs/quote@2.5.0:
resolution: {integrity: sha512-24Mu8cESaj34a0kg1Enj7qiZ3yiCOsZI59+8xpfXLO/NkO7hBYWNForVcBy5yIWs/VLlEZK11FP37f/mHrKugQ==}
dependencies:
'@codexteam/icons': 0.0.5
dev: true
/@editorjs/raw@2.4.0:
resolution: {integrity: sha512-6k7ngx1T8+ztTG4/i5QcGPKXkF2YvdqgKgtzpOTaG6Pzm17D7Hr30Krbqz2E2Y/uoV8SiR/X1UAyKTQxrk9B6Q==}
dependencies:
'@codexteam/icons': 0.0.4
dev: true
/@editorjs/table@2.2.1:
resolution: {integrity: sha512-6RJKF0DfPh7YuBVPmT5mHXjjhil2IJ8ytZpw2hTHAuduqoRlu6OJbaGzH2NTCdg7cgD4H4BbX0RB9kcVxRtB2w==}
dependencies:
'@codexteam/icons': 0.0.6
dev: true
/@editorjs/underline@1.1.0:
resolution: {integrity: sha512-vQj2ROW1KreD31QHlhaPikmDJGWYzRBusN4Zyfwl9nIIQCByt4S8fZQpsrRvH4sct5mkirsHllNT00rJlqHK7Q==}
dependencies:
'@codexteam/icons': 0.0.6
dev: true
/@emotion/use-insertion-effect-with-fallbacks@1.0.0(react@18.0.0):
resolution: {integrity: sha512-1eEgUGmkaljiBnRMTdksDV1W4kUnmwgp7X9G8B++9GYwl1lUdqSndSriIrTJ0N7LQaoauY9JJ2yhiOYK5+NI4A==}
peerDependencies:
@@ -9082,6 +9249,14 @@ packages:
resolution: {integrity: sha512-z2jlHBocElRnPYysN2HAuhXbO3DNB0bcSKmNz3hcWR2Js2Dkhc1bEOxG93Z3DeUrnm+qx56XOY5wQmbP5KY0sw==}
dev: true
/codex-notifier@1.1.2:
resolution: {integrity: sha512-DCp6xe/LGueJ1N5sXEwcBc3r3PyVkEEDNWCVigfvywAkeXcZMk9K41a31tkEFBW0Ptlwji6/JlAb49E3Yrxbtg==}
dev: true
/codex-tooltip@1.0.5:
resolution: {integrity: sha512-IuA8LeyLU5p1B+HyhOsqR6oxyFQ11k3i9e9aXw40CrHFTRO2Y1npNBVU3W1SvhKAbUU7R/YikUBdcYFP0RcJag==}
dev: true
/collect-v8-coverage@1.0.1:
resolution: {integrity: sha512-iBPtljfCNcTKNAto0KEtDfZ3qzjJvqE3aTGZsbhjSBlorqpXJlaWWtPO35D+ZImoC3KWejX64o+yPGxhWSTzfg==}
dev: true
@@ -10129,6 +10304,16 @@ packages:
sigmund: 1.0.1
dev: true
/editorjs-text-alignment-blocktune@1.0.3:
resolution: {integrity: sha512-DAJ2LJP+WjETvU4lVOJCqZ1kZkKHp0GkujkqZgza9DRv7bO46m1M/s2d5Ba5hPFdmqk1vA/ci++MQXgWD7mWYw==}
dev: true
/editorjs-toggle-block@0.3.11:
resolution: {integrity: sha512-Z0/0/OKofiPU5rxlNcz7U/VANK0PGMWyKQ2tVZytMaoyHmqZXmpa68xKATvxO9ldher6873wjgOWT6ZK1bi5YA==}
dependencies:
uuid: 9.0.0
dev: true
/ee-first@1.1.1:
resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==}
@@ -11691,6 +11876,10 @@ packages:
resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==}
dev: true
/html-janitor@2.0.4:
resolution: {integrity: sha512-92J5h9jNZRk30PMHapjHEJfkrBWKCOy0bq3oW2pBungky6lzYSoboBGPMvxl1XRKB2q+kniQmsLsPbdpY7RM2g==}
dev: true
/htmlparser2@8.0.2:
resolution: {integrity: sha512-GYdjWKDkbRLkZ5geuHs5NY1puJ+PXwP7+fHPRz06Eirsb9ugf6d8kkXav6ADhcODhFFPMIXyxkxSuMf3D6NCFA==}
dependencies: