How can I enforce only lists entry? #4515
-
I want that, right from when I click in the text area, I can only enter list items, and on pressing the enter key, a new list item is created rather than a paragraph. Also, when copying and pasting, paragraphs are converted to list items. Given that I'm not taking in anything other than text, I'd prefer that texts are always be wrapped in an |
Beta Was this translation helpful? Give feedback.
Replies: 1 comment
-
Just in case anyone comes to this at a later time, here's how I implemented it. The only issue now is that I'm still trying to get it to allow the backspace key to delete an empty list item. import React, { type JSX, useEffect, useRef, useState } from 'react';
import { cn } from '@/lib/utils';
interface QuillListEditorProps {
value: string;
onChange: (value: string) => void;
placeholder?: string;
readOnly?: boolean;
className?: string;
error?: boolean;
}
const QuillListEditor = ({
value,
onChange,
placeholder = 'Type your list items...',
readOnly = false,
className,
error,
}: QuillListEditorProps): JSX.Element | null => {
const editorRef = useRef<HTMLDivElement>(null);
const quillRef = useRef<any>(null);
const isFormattingRef = useRef(false);
const [isClient, setIsClient] = useState(false);
useEffect(() => {
setIsClient(true);
}, []);
// Initialize Quill once
useEffect(() => {
if (!isClient || !editorRef.current || quillRef.current) return;
const initQuill = async () => {
const Quill = (await import('quill')).default;
const Delta = Quill.import('delta');
// Create a custom clipboard matcher that always creates a list
const clipboard = Quill.import('modules/clipboard');
(clipboard as any).matchers = [
['*', (_node: any, delta: any) => {
const ops = delta.ops.map((op: any) => ({
...op,
attributes: { ...op.attributes, list: 'bullet' }
}));
return new Delta(ops);
}]
];
// Initialize Quill with minimal configuration
const quill = new Quill(editorRef.current as HTMLDivElement, {
theme: 'snow',
modules: {
toolbar: false,
clipboard: {
matchVisual: false
},
keyboard: {
bindings: {
'list autofill': {
key: 'enter',
shiftKey: null,
handler: function() {
const range = this.quill.getSelection();
if (range) {
this.quill.insertText(range.index, '\n', { list: 'bullet' });
return false;
}
return true;
}
},
'remove list item': {
key: 'backspace',
handler: function(range: any) {
console.log('backspace key was hit');
if (range.index === 0 && range.length === 0) {
const [line] = this.quill.getLine(range.index);
if (line.cache.length <= 1) { // Empty line
this.quill.deleteText(range.index - 1, 1);
return false;
}
}
return true;
}
},
}
}
},
formats: ['list']
});
// quill.keyboard.addBinding({ key: 'Backspace' }, {
// empty: true, // implies collapsed: true and offset: 0
// format: ['list']
// }, function(range, context) {
// this.quill.format('list', false);
// });
// Apply initial format and content
quill.root.innerHTML = value ? ensureListFormat(value) : '<ul><li><br></li></ul>';
// Handle text changes
quill.on('text-change', () => {
if (isFormattingRef.current) return;
try {
isFormattingRef.current = true;
// Get current content before formatting
const delta = quill.getContents();
// Only format if there are actual changes
if (delta.ops.some(op => !op.attributes?.list)) {
const length = quill.getLength();
quill.formatLine(0, length, 'list', 'bullet');
}
// Get final content and trigger change
const content = cleanQuillHtml(quill.root.innerHTML);
if (content !== value) {
onChange(content);
}
} finally {
isFormattingRef.current = false;
}
});
// Ensure empty editor starts with a list
quill.on('editor-change', (eventName: string) => {
if (eventName === 'text-change' && quill.getText().trim() === '') {
if (isFormattingRef.current) return;
try {
isFormattingRef.current = true;
quill.formatLine(0, 1, 'list', 'bullet');
} finally {
isFormattingRef.current = false;
}
}
});
quillRef.current = quill;
};
initQuill();
// Cleanup
return () => {
if (quillRef.current) {
quillRef.current.off('text-change');
quillRef.current.off('editor-change');
quillRef.current = null;
}
};
}, [isClient]);
// Handle value updates
useEffect(() => {
if (!quillRef.current || isFormattingRef.current) return;
const content = cleanQuillHtml(quillRef.current.root.innerHTML);
if (value !== content) {
const formattedContent = ensureListFormat(value);
isFormattingRef.current = true;
try {
quillRef.current.root.innerHTML = formattedContent;
} finally {
isFormattingRef.current = false;
}
}
}, [value]);
// Handle readonly updates
useEffect(() => {
if (quillRef.current) {
quillRef.current.enable(!readOnly);
}
}, [readOnly]);
// Handle placeholder updates
useEffect(() => {
if (quillRef.current) {
quillRef.current.root.dataset.placeholder = placeholder;
}
}, [placeholder]);
if (!isClient) {
return null;
}
return (
<div className={cn(
'relative rounded-md border bg-background',
error && 'border-destructive',
readOnly && 'bg-muted cursor-not-allowed opacity-60',
className
)}>
<div ref={editorRef} className={cn(
'prose prose-sm max-w-none dark:prose-invert',
'[&_.ql-container]:min-h-[120px]',
'[&_.ql-editor]:py-3 [&_.ql-editor]:px-4',
'[&_.ql-editor]:rounded-md',
// Style unordered lists
'[&_.ql-editor_ul]:list-disc [&_.ql-editor_ul]:pl-6',
'[&_.ql-editor_ul>li]:pl-1.5',
'[&_.ql-editor_ul>li]:my-1',
// Remove default Quill list styling
'[&_.ql-editor_.ql-indent-1]:pl-0',
'[&_.ql-editor_.ql-indent-1]:ml-0'
)} />
</div>
);
};
// Returns a clean HTML list (no attributes in any of the tags)
function cleanQuillHtml(html: string): string {
const tempDiv = document.createElement('div');
tempDiv.innerHTML = html;
// Remove all Quill UI elements
tempDiv.querySelectorAll('.ql-ui').forEach(el => el.remove());
// Remove direct br elements that come after li elements
tempDiv.querySelectorAll('li + br').forEach(el => el.remove());
// Clean up li elements
tempDiv.querySelectorAll('li').forEach(li => {
// Remove all attributes from li
while (li.attributes.length > 0) {
li.removeAttribute(li.attributes[0].name);
}
// Remove trailing br if li has content
if (li.lastElementChild?.tagName === 'BR' && li.textContent?.trim()) {
li.lastElementChild.remove();
}
});
return tempDiv.innerHTML;
}
function ensureListFormat(content: string): string {
if (!content) {
return '<ul><li><br></li></ul>';
}
const tempDiv = document.createElement('div');
tempDiv.innerHTML = content;
if (!tempDiv.querySelector('ul')) {
const lines = content
.split('\n')
.filter(line => line.trim())
.map(line => `<li>${line}</li>`)
.join('');
return lines ? `<ul>${lines}</ul>` : '<ul><li><br></li></ul>';
}
return content;
}
export default QuillListEditor; |
Beta Was this translation helpful? Give feedback.
Just in case anyone comes to this at a later time, here's how I implemented it. The only issue now is that I'm still trying to get it to allow the backspace key to delete an empty list item.