From 89acb0834054327488c31ad65a07502057e2f6fc Mon Sep 17 00:00:00 2001 From: Lei OT Date: Fri, 7 Nov 2025 16:11:10 +0800 Subject: [PATCH] build: + Quill editor --- package.json | 1 + src/components/Editor/custom.snow.css | 14 +++ src/components/Editor/index.jsx | 130 ++++++++++++++++++++++++++ 3 files changed, 145 insertions(+) create mode 100644 src/components/Editor/custom.snow.css create mode 100644 src/components/Editor/index.jsx diff --git a/package.json b/package.json index 7d2bb1c..5179a2c 100644 --- a/package.json +++ b/package.json @@ -20,6 +20,7 @@ "i18next": "^23.11.5", "i18next-browser-languagedetector": "^8.0.0", "i18next-http-backend": "^2.5.2", + "quill": "^2.0.3", "react": "^18.3.1", "react-dom": "^18.3.1", "react-i18next": "^14.1.2", diff --git a/src/components/Editor/custom.snow.css b/src/components/Editor/custom.snow.css new file mode 100644 index 0000000..3634988 --- /dev/null +++ b/src/components/Editor/custom.snow.css @@ -0,0 +1,14 @@ +.quill-editor .ql-container{ + font-size: 14px; +} +.quill-editor .ql-container.ql-snow { + min-height: 12rem; + border-color: #d9d9d9; +} +.quill-editor .ql-toolbar.ql-snow { + border-radius: 6px 6px 0 0; + border-color: #d9d9d9; +} +.quill-editor .ql-editor{ + min-height: 12rem; +} diff --git a/src/components/Editor/index.jsx b/src/components/Editor/index.jsx new file mode 100644 index 0000000..9351c3c --- /dev/null +++ b/src/components/Editor/index.jsx @@ -0,0 +1,130 @@ +import React, { forwardRef, useEffect, useLayoutEffect, useRef } from 'react'; +import Quill from 'quill'; +import 'quill/dist/quill.snow.css'; +// import 'quill/dist/quill.bubble.css'; +import './custom.snow.css'; + +const toolbarOptions = [ + [{ 'header': [1, 2, 3, 4, 5, 6, false] }], + // [{ 'font': [] }], + ['bold', 'italic', 'underline', 'strike'], // toggled buttons + // ['blockquote'], // 'code-block' + ['link', ], // 'image' 'video', 'formula' + + [{ 'list': 'ordered' }, { 'list': 'bullet' }], // { 'list': 'check' } + [{ 'color': [] }, { 'background': [] }], // dropdown with defaults from theme + + // [{ 'size': ['small', false, 'large', 'huge'] }], // custom dropdown + // [{ 'indent': '-1' }, { 'indent': '+1' }], // outdent/indent + // [{ 'align': [] }], + + ['clean'], // remove formatting button +]; + +// Editor is an uncontrolled React component +const Editor = forwardRef(({ readOnly, defaultValue, value, onChange, onTextChange, onSelectionChange }, ref) => { + const containerRef = useRef(null); + const cntContainerRef = useRef(null); + const defaultValueRef = useRef(defaultValue); + const valueRef = useRef(value); + const onTextChangeRef = useRef(onTextChange); + const onChangeRef = useRef(onChange); + const onSelectionChangeRef = useRef(onSelectionChange); + + useLayoutEffect(() => { + onTextChangeRef.current = onTextChange; + onChangeRef.current = onChange; + onSelectionChangeRef.current = onSelectionChange; + }); + + useEffect(() => { + const editor = ref.current; + if (!editor) return; + + valueRef.current = value; + const current = editor.root.innerHTML; + const incoming = value || defaultValue || ''; + // treat quill empty markup as empty string + const currentNormalized = current === '


' ? '' : current; + + // if (incoming !== currentNormalized) { + if (!currentNormalized) { // ! 只设置一次value + const value = valueRef.current; + const delta = editor.clipboard.convert({ html: value }); + editor.setContents(delta, 'silent'); + + const sel = editor.getSelection && editor.getSelection(); + // restore selection if possible + if (sel && sel.index != null) { + try { editor.setSelection(Math.min(sel.index, editor.getLength() - 1), sel.length || 0); } catch {} + } + } + }, [ref, defaultValue, value]); + + useEffect(() => { + ref.current?.enable(!readOnly); + }, [ref, readOnly]); + + useEffect(() => { + const container = containerRef.current; + const cntContainer = cntContainerRef.current; + const editorContainer = container.appendChild(container.ownerDocument.createElement('div')); + + // Add fonts to whitelist + // const Font = Quill.import('formats/font'); + // Font.whitelist = ['mirza', 'roboto']; + // Quill.register(Font, true); + + Quill.register('modules/counter', function (quill, options) { + // quill.on('text-change', function() { + quill.on(Quill.events.TEXT_CHANGE, () => { + // const text = quill.getText(); + const length = quill.getLength(); + cntContainer.innerText = length; // text.length; // text.split(/\s+/).length; + }); + }); + + const quill = new Quill(editorContainer, { + theme: 'snow', + modules: { + toolbar: toolbarOptions, + counter: true, + }, + }); + + ref.current = quill; + + // if (defaultValueRef.current) { + // quill.setContents(defaultValueRef.current); + // } + + quill.on(Quill.events.TEXT_CHANGE, (...args) => { + // const text = quill.getText(); + onTextChangeRef.current?.(...args); + const html = quill.getSemanticHTML(); + onChangeRef.current?.(html); + }); + + quill.on(Quill.events.SELECTION_CHANGE, (...args) => { + onSelectionChangeRef.current?.(...args); + }); + + return () => { + ref.current = null; + container.innerHTML = ''; + }; + }, [ref]); + + return ( + <> +
+
+ 0 / 4000 +
+ + ); +}); + +Editor.displayName = 'Editor'; + +export default Editor;