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:
Jay Cammarano
2022-01-20 15:58:57 -05:00
committed by GitHub
parent 595eb696ea
commit b96d7775d3
15 changed files with 2077 additions and 685 deletions

View File

@@ -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',

View File

@@ -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>

View File

@@ -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',

View File

@@ -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%;

View File

@@ -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',

View File

@@ -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),

View File

@@ -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',

View File

@@ -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(() => {