mirror of
https://github.com/directus/directus.git
synced 2026-02-16 20:52:13 -05:00
Add soft character limit to various inputs (#11009)
* added soft_length to lang * added softLength option to input interface * softLength => textarea, md, wysiwyg * really broken but counting characters * return 0 not null oops * characters remaining displaying * percentageRemaining => shared * placeholders => string * markdown inputs need to change preview css * account for multiple md inputs * works for multiple inputs on a page * let it breathe * text area but no warning color (yet) * newline is 1 char * null => undefined * shows with 0 hard limit left * softlength tied to maxlength * preview displaying md * using share util * Replace shared "interface" with util * Add test setup * Lock package versions Co-authored-by: rijkvanzanten <rijkvanzanten@me.com>
This commit is contained in:
@@ -23,6 +23,19 @@ export default defineInterface({
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
field: 'softLength',
|
||||
name: '$t:soft_length',
|
||||
type: 'integer',
|
||||
meta: {
|
||||
width: 'half',
|
||||
interface: 'input',
|
||||
options: {
|
||||
placeholder: '255',
|
||||
min: 1,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
field: 'trim',
|
||||
name: '$t:interfaces.input.trim',
|
||||
|
||||
@@ -6,11 +6,24 @@
|
||||
:disabled="disabled"
|
||||
:class="font"
|
||||
@update:model-value="$emit('input', $event)"
|
||||
/>
|
||||
>
|
||||
<template v-if="(percentageRemaining && percentageRemaining <= 20) || softLength" #append>
|
||||
<span
|
||||
v-if="(percentageRemaining && percentageRemaining <= 20) || softLength"
|
||||
class="remaining"
|
||||
:class="{
|
||||
warning: percentageRemaining < 10,
|
||||
danger: percentageRemaining < 5,
|
||||
}"
|
||||
>
|
||||
{{ charsRemaining }}
|
||||
</span>
|
||||
</template>
|
||||
</v-textarea>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent, PropType } from 'vue';
|
||||
import { computed, defineComponent, PropType } from 'vue';
|
||||
|
||||
export default defineComponent({
|
||||
props: {
|
||||
@@ -38,8 +51,38 @@ export default defineComponent({
|
||||
type: String as PropType<'sans-serif' | 'serif' | 'monospace'>,
|
||||
default: 'sans-serif',
|
||||
},
|
||||
softLength: {
|
||||
type: Number,
|
||||
default: undefined,
|
||||
},
|
||||
},
|
||||
emits: ['input'],
|
||||
setup(props) {
|
||||
const charsRemaining = computed(() => {
|
||||
if (typeof props.value === 'number') return null;
|
||||
|
||||
if (!props.softLength) return null;
|
||||
if (!props.value && props.softLength) return props.softLength;
|
||||
const realValue = props.value.replaceAll('\n', ' ').length;
|
||||
|
||||
if (props.softLength) return +props.softLength - realValue;
|
||||
return null;
|
||||
});
|
||||
|
||||
const percentageRemaining = computed(() => {
|
||||
if (typeof props.value === 'number') return null;
|
||||
|
||||
if (!props.softLength) return null;
|
||||
if (!props.value) return 100;
|
||||
|
||||
if (props.softLength) return 100 - (props.value.length / +props.softLength) * 100;
|
||||
return 100;
|
||||
});
|
||||
return {
|
||||
charsRemaining,
|
||||
percentageRemaining,
|
||||
};
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -57,4 +100,28 @@ export default defineComponent({
|
||||
--v-textarea-font-family: var(--family-sans-serif);
|
||||
}
|
||||
}
|
||||
|
||||
.remaining {
|
||||
position: absolute;
|
||||
right: 10px;
|
||||
bottom: 5px;
|
||||
width: 24px;
|
||||
color: var(--foreground-subdued);
|
||||
font-weight: 600;
|
||||
text-align: right;
|
||||
vertical-align: middle;
|
||||
font-feature-settings: 'tnum';
|
||||
}
|
||||
|
||||
.v-input:focus-within .remaining {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.v-input:focus-within .hide {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.warning {
|
||||
color: var(--warning);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -268,6 +268,19 @@ export default defineInterface({
|
||||
},
|
||||
],
|
||||
advanced: [
|
||||
{
|
||||
field: 'softLength',
|
||||
name: '$t:soft_length',
|
||||
type: 'integer',
|
||||
meta: {
|
||||
width: 'half',
|
||||
interface: 'input',
|
||||
options: {
|
||||
placeholder: '255',
|
||||
min: 1,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
field: 'customFormats',
|
||||
name: '$t:interfaces.input-rich-text-html.custom_formats',
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<div class="wysiwyg" :class="{ disabled }">
|
||||
<div :id="field" class="wysiwyg" :class="{ disabled }">
|
||||
<editor
|
||||
ref="editorElement"
|
||||
v-model="internalValue"
|
||||
@@ -10,7 +10,17 @@
|
||||
@focusin="setFocus(true)"
|
||||
@focusout="setFocus(false)"
|
||||
/>
|
||||
|
||||
<template v-if="softLength">
|
||||
<span
|
||||
class="remaining"
|
||||
:class="{
|
||||
warning: percRemaining < 10,
|
||||
danger: percRemaining < 5,
|
||||
}"
|
||||
>
|
||||
{{ softLength - count }}
|
||||
</span>
|
||||
</template>
|
||||
<v-dialog v-model="linkDrawerOpen">
|
||||
<v-card>
|
||||
<v-card-title class="card-title">{{ t('wysiwyg_options.link') }}</v-card-title>
|
||||
@@ -149,7 +159,7 @@
|
||||
|
||||
<script lang="ts">
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { defineComponent, PropType, ref, computed, toRefs, ComponentPublicInstance } from 'vue';
|
||||
import { defineComponent, PropType, ref, computed, toRefs, ComponentPublicInstance, onMounted } from 'vue';
|
||||
|
||||
import 'tinymce/tinymce';
|
||||
import 'tinymce/themes/silver';
|
||||
@@ -179,6 +189,7 @@ import useLink from './useLink';
|
||||
import useSourceCode from './useSourceCode';
|
||||
import { getToken } from '@/api';
|
||||
import { getPublicURL } from '@/utils/get-root-path';
|
||||
import { percentage } from '@/utils/percentage';
|
||||
|
||||
type CustomFormat = {
|
||||
title: string;
|
||||
@@ -195,6 +206,10 @@ export default defineComponent({
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
field: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
toolbar: {
|
||||
type: Array as PropType<string[] | null>,
|
||||
default: () => [
|
||||
@@ -240,16 +255,40 @@ export default defineComponent({
|
||||
type: String,
|
||||
default: undefined,
|
||||
},
|
||||
softLength: {
|
||||
type: Number,
|
||||
default: undefined,
|
||||
},
|
||||
},
|
||||
emits: ['input'],
|
||||
setup(props, { emit }) {
|
||||
const { t } = useI18n();
|
||||
|
||||
const editorRef = ref<any | null>(null);
|
||||
const editorElement = ref<ComponentPublicInstance | null>(null);
|
||||
const isEditorDirty = ref(false);
|
||||
const { imageToken } = toRefs(props);
|
||||
|
||||
let tinymceEditor: HTMLElement | null;
|
||||
let count = ref(0);
|
||||
onMounted(() => {
|
||||
let iframe;
|
||||
const wysiwyg = document.getElementById(props.field);
|
||||
|
||||
if (wysiwyg) iframe = wysiwyg.getElementsByTagName('iframe');
|
||||
|
||||
if (iframe && iframe[0] && iframe[0].contentWindow)
|
||||
tinymceEditor = iframe[0].contentWindow.document.getElementById('tinymce');
|
||||
|
||||
if (tinymceEditor) {
|
||||
const observer = new MutationObserver((_mutations) => {
|
||||
count.value = tinymceEditor?.textContent?.replace('\n', '')?.length ?? 0;
|
||||
});
|
||||
|
||||
const config = { characterData: true, childList: true, subtree: true };
|
||||
observer.observe(tinymceEditor, config);
|
||||
}
|
||||
});
|
||||
|
||||
const { imageDrawerOpen, imageSelection, closeImageDrawer, onImageSelect, saveImage, imageButton } = useImage(
|
||||
editorRef,
|
||||
isEditorDirty,
|
||||
@@ -361,8 +400,12 @@ export default defineComponent({
|
||||
};
|
||||
});
|
||||
|
||||
const percRemaining = computed(() => percentage(count.value, props.softLength));
|
||||
|
||||
return {
|
||||
t,
|
||||
percRemaining,
|
||||
count,
|
||||
editorElement,
|
||||
editorOptions,
|
||||
internalValue,
|
||||
@@ -440,6 +483,26 @@ export default defineComponent({
|
||||
@include form-grid;
|
||||
}
|
||||
|
||||
.remaining {
|
||||
position: absolute;
|
||||
right: 10px;
|
||||
bottom: 5px;
|
||||
width: 24px;
|
||||
color: var(--foreground-subdued);
|
||||
font-weight: 600;
|
||||
text-align: right;
|
||||
vertical-align: middle;
|
||||
font-feature-settings: 'tnum';
|
||||
}
|
||||
|
||||
.warning {
|
||||
color: var(--warning);
|
||||
}
|
||||
|
||||
.danger {
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.image-preview,
|
||||
.media-preview {
|
||||
width: 100%;
|
||||
|
||||
@@ -123,6 +123,19 @@ export default defineInterface({
|
||||
},
|
||||
],
|
||||
advanced: [
|
||||
{
|
||||
field: 'softLength',
|
||||
name: '$t:soft_length',
|
||||
type: 'integer',
|
||||
meta: {
|
||||
width: 'half',
|
||||
interface: 'input',
|
||||
options: {
|
||||
placeholder: '255',
|
||||
min: 1,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
field: 'editorFont',
|
||||
name: '$t:interfaces.input-rich-text-md.editorFont',
|
||||
|
||||
@@ -163,8 +163,22 @@
|
||||
</div>
|
||||
|
||||
<div ref="codemirrorEl"></div>
|
||||
|
||||
<div v-if="view[0] === 'preview'" v-md="markdownString" class="preview-box"></div>
|
||||
<template v-if="softLength">
|
||||
<span
|
||||
class="remaining"
|
||||
:class="{
|
||||
warning: percRemaining < 10,
|
||||
danger: percRemaining < 5,
|
||||
}"
|
||||
>
|
||||
{{ softLength - count }}
|
||||
</span>
|
||||
</template>
|
||||
<div
|
||||
v-md="markdownString"
|
||||
class="preview-box"
|
||||
:style="view[0] === 'preview' ? 'display:block' : 'display:none'"
|
||||
></div>
|
||||
|
||||
<v-dialog :model-value="imageDialogOpen" @esc="imageDialogOpen = null" @update:model-value="imageDialogOpen = null">
|
||||
<v-card>
|
||||
@@ -194,6 +208,7 @@ import { addTokenToURL } from '@/api';
|
||||
import escapeStringRegexp from 'escape-string-regexp';
|
||||
import useShortcut from '@/composables/use-shortcut';
|
||||
import translateShortcut from '@/utils/translate-shortcut';
|
||||
import { percentage } from '@/utils/percentage';
|
||||
|
||||
export default defineComponent({
|
||||
props: {
|
||||
@@ -243,6 +258,10 @@ export default defineComponent({
|
||||
type: String,
|
||||
default: undefined,
|
||||
},
|
||||
softLength: {
|
||||
type: Number,
|
||||
default: undefined,
|
||||
},
|
||||
folder: {
|
||||
type: String,
|
||||
default: undefined,
|
||||
@@ -260,6 +279,8 @@ export default defineComponent({
|
||||
|
||||
const imageDialogOpen = ref(false);
|
||||
|
||||
let count = ref(0);
|
||||
|
||||
onMounted(async () => {
|
||||
if (codemirrorEl.value) {
|
||||
codemirror = CodeMirror(codemirrorEl.value, {
|
||||
@@ -278,6 +299,18 @@ export default defineComponent({
|
||||
emit('input', content);
|
||||
});
|
||||
}
|
||||
|
||||
if (markdownInterface.value) {
|
||||
const previewBox = markdownInterface.value.getElementsByClassName('preview-box')[0];
|
||||
|
||||
const observer = new MutationObserver(() => {
|
||||
count.value = previewBox.textContent?.replace('\n', '')?.length ?? 0;
|
||||
});
|
||||
|
||||
const config = { characterData: true, childList: true, subtree: true };
|
||||
|
||||
observer.observe(previewBox, config);
|
||||
}
|
||||
});
|
||||
|
||||
watch(
|
||||
@@ -326,6 +359,7 @@ export default defineComponent({
|
||||
columns: 4,
|
||||
});
|
||||
|
||||
const percRemaining = computed(() => percentage(count.value, props.softLength));
|
||||
useShortcut('meta+b', () => edit('bold'), markdownInterface);
|
||||
useShortcut('meta+i', () => edit('italic'), markdownInterface);
|
||||
useShortcut('meta+k', () => edit('link'), markdownInterface);
|
||||
@@ -341,6 +375,8 @@ export default defineComponent({
|
||||
|
||||
return {
|
||||
t,
|
||||
percRemaining,
|
||||
count,
|
||||
codemirrorEl,
|
||||
edit,
|
||||
view,
|
||||
@@ -399,6 +435,7 @@ textarea {
|
||||
}
|
||||
|
||||
.preview-box {
|
||||
display: none;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
@@ -484,6 +521,26 @@ textarea {
|
||||
line-height: 24px;
|
||||
}
|
||||
|
||||
.remaining {
|
||||
position: absolute;
|
||||
right: 10px;
|
||||
bottom: 5px;
|
||||
width: 24px;
|
||||
color: var(--foreground-subdued);
|
||||
font-weight: 600;
|
||||
text-align: right;
|
||||
vertical-align: middle;
|
||||
font-feature-settings: 'tnum';
|
||||
}
|
||||
|
||||
.warning {
|
||||
color: var(--warning);
|
||||
}
|
||||
|
||||
.danger {
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.preview-box :deep(ul ul),
|
||||
.preview-box :deep(ol ol),
|
||||
.preview-box :deep(ul ol),
|
||||
|
||||
@@ -45,6 +45,20 @@ export default defineInterface({
|
||||
},
|
||||
],
|
||||
advanced: [
|
||||
{
|
||||
field: 'softLength',
|
||||
name: '$t:soft_length',
|
||||
type: 'integer',
|
||||
meta: {
|
||||
width: 'half',
|
||||
interface: 'input',
|
||||
options: {
|
||||
placeholder: '255',
|
||||
min: 1,
|
||||
max: field.schema?.max_length,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
field: 'font',
|
||||
name: '$t:font',
|
||||
|
||||
@@ -16,9 +16,9 @@
|
||||
@update:model-value="$emit('input', $event)"
|
||||
>
|
||||
<template v-if="iconLeft" #prepend><v-icon :name="iconLeft" /></template>
|
||||
<template v-if="(percentageRemaining && percentageRemaining <= 20) || iconRight" #append>
|
||||
<template v-if="(percentageRemaining !== null && percentageRemaining <= 20) || iconRight || softLength" #append>
|
||||
<span
|
||||
v-if="percentageRemaining && percentageRemaining <= 20"
|
||||
v-if="(percentageRemaining !== null && percentageRemaining <= 20) || softLength"
|
||||
class="remaining"
|
||||
:class="{
|
||||
warning: percentageRemaining < 10,
|
||||
@@ -81,6 +81,10 @@ export default defineComponent({
|
||||
type: Number,
|
||||
default: null,
|
||||
},
|
||||
softLength: {
|
||||
type: Number,
|
||||
default: undefined,
|
||||
},
|
||||
dbSafe: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
@@ -112,17 +116,24 @@ export default defineComponent({
|
||||
const charsRemaining = computed(() => {
|
||||
if (typeof props.value === 'number') return null;
|
||||
|
||||
if (!props.length) return null;
|
||||
if (!props.value) return null;
|
||||
return +props.length - props.value.length;
|
||||
if (!props.length && !props.softLength) return null;
|
||||
if (!props.value && !props.softLength) return null;
|
||||
if (!props.value && props.softLength) return props.softLength;
|
||||
if (props.softLength) return +props.softLength - props.value.length;
|
||||
if (props.length) return +props.length - props.value.length;
|
||||
return null;
|
||||
});
|
||||
|
||||
const percentageRemaining = computed(() => {
|
||||
if (typeof props.value === 'number') return null;
|
||||
|
||||
if (!props.length) return null;
|
||||
if (!props.value) return null;
|
||||
return 100 - (props.value.length / +props.length) * 100;
|
||||
if (!props.length && !props.softLength) return null;
|
||||
if (!props.value) return 100;
|
||||
|
||||
if (props.softLength) return 100 - (props.value.length / +props.softLength) * 100;
|
||||
if (props.length) return 100 - (props.value.length / +props.length) * 100;
|
||||
|
||||
return 100;
|
||||
});
|
||||
|
||||
const inputType = computed(() => {
|
||||
|
||||
Reference in New Issue
Block a user