diff --git a/public/react/src/AppConfig.js b/public/react/src/AppConfig.js index 924bd810e..a180777bf 100644 --- a/public/react/src/AppConfig.js +++ b/public/react/src/AppConfig.js @@ -47,8 +47,8 @@ export function initAxiosInterceptors(props) { // TODO 读取到package.json中的配置? var //proxy = "http://localhost:3000" - // proxy="http://123.59.135.93:56666" - proxy="http://localhost:3000" + proxy="http://123.59.135.93:56666" + // proxy="http://localhost:3000" // 在这里使用requestMap控制,避免用户通过双击等操作发出重复的请求; // 如果需要支持重复的请求,考虑config里面自定义一个allowRepeat参考来控制 diff --git a/public/react/src/forge/quillForEditor/FillBlot.js b/public/react/src/forge/quillForEditor/FillBlot.js new file mode 100644 index 000000000..5e5e2aa77 --- /dev/null +++ b/public/react/src/forge/quillForEditor/FillBlot.js @@ -0,0 +1,35 @@ +/* + * @Description: 填空 + * @Author: tangjiang + * @Github: + * @Date: 2020-01-06 09:02:29 + * @LastEditors : tangjiang + * @LastEditTime : 2020-02-05 10:44:01 + */ +import Quill from 'quill'; +let Inline = Quill.import('blots/inline'); +// const BlockEmbed = Quill.import('blots/embed'); +class FillBlot extends Inline { + static create (value) { + const node = super.cerate(value); + // node.classList.add('icon icon-bianji2'); + // node.setAttribute('data-fill', 'fill'); + console.log('编辑器值===》》》》》', value); + node.setAttribute('data_index', value.data_index); + node.nodeValue = value.text; + return node; + } + + static value (node) { + return { + // dataSet: node.getAttribute('data-fill'), + data_index: node.getAttribute('data_index') + } + } +} + + +FillBlot.blotName = "fill"; +FillBlot.tagName = "span"; + +export default FillBlot; diff --git a/public/react/src/forge/quillForEditor/ImageBlot.js b/public/react/src/forge/quillForEditor/ImageBlot.js new file mode 100644 index 000000000..5ff84b249 --- /dev/null +++ b/public/react/src/forge/quillForEditor/ImageBlot.js @@ -0,0 +1,70 @@ +/* + * @Description: 重写图片 + * @Author: tangjiang + * @Github: + * @Date: 2019-12-16 15:50:45 + * @LastEditors : tangjiang + * @LastEditTime : 2019-12-31 13:59:02 + */ +import Quill from "quill"; + +const BlockEmbed = Quill.import('blots/block/embed'); + +export default class ImageBlot extends BlockEmbed { + + static create(value) { + + const node = super.create(); + node.setAttribute('alt', value.alt); + node.setAttribute('src', value.url); + // console.log('~~~~~~~~~~~', node, value); + node.addEventListener('click', function () { + value.onclick(value.url); + }, false); + if (value.width) { + node.setAttribute('width', value.width); + } + if (value.height) { + node.setAttribute('height', value.height); + } + + if (value.id) { + node.setAttribute('id', value.id); + } + // 宽度和高度都不存在时, + if (!value.width && !value.height) { + // node.setAttribute('display', 'block'); + node.setAttribute('width', '100%'); + } + + // node.setAttribute('style', { cursor: 'pointer' }); + + // if (node.onclick) { + // console.log('image 有图片点击事件======》》》》》》'); + // // node.setAttribute('onclick', node.onCLick); + // } + // 给图片添加点击事件 + // node.onclick = () => { + // value.onClick && value.onClick(value.url); + // } + return node; + } + + // 获取节点值 + static value (node) { + + return { + alt: node.getAttribute('alt'), + url: node.getAttribute('src'), + onclick: node.onclick, + width: node.width, + height: node.height, + display: node.getAttribute('display'), + id: node.id, + // style: node.style + }; + } +} + +ImageBlot.blotName = 'image'; +ImageBlot.tagName = 'img'; \ No newline at end of file diff --git a/public/react/src/forge/quillForEditor/README.md b/public/react/src/forge/quillForEditor/README.md new file mode 100644 index 000000000..c9c53b902 --- /dev/null +++ b/public/react/src/forge/quillForEditor/README.md @@ -0,0 +1,95 @@ + +## QuillForEditor 使用 [https://quilljs.com/] + + ### 导入 + + - 目录 src/common/quillForEditor (默认加载当前文件夹下的 index.js 文件) + + ### 参数 + + | 字段 | 描述 | + | ----- | ----- | + | placeholder | 提示信息 | + | readOnly | 只读(只读取模式时,没有 工具栏且内容不可编辑,通常用于展示quill内容) | + | autoFocus | 自动获得焦点 | + | options | 配置参数, 指定工具栏内容 | + | value | 文本编辑器内容 | + | imgAttrs | 指定上传图片的尺寸 { width: 'xxpx}, height: 'xxpx'| + | style | 指定quill容器样式 | + | wrapStyle | 指定包裹quill容器的样式| + | onContentChange | 当编辑器内容变化时调用此回调函数(注: 此时返回的内容为对象,提交到后台时需要格式成 JSON 字符串: JSON.stringify(xx)) | + | showUploadImage | 点击放大上传成功后的图片, 返回上传成功后的图片 url, (评论时点击图片这么大)| + + + + ### 添加工具栏 + + - 默认所有的 + + ``` + const options = [ + 'bold', // 加粗 + 'italic', // 斜体 + 'underline', // 下划线 + {size: ['12px', '14px', '16px', '18px', '20px']}, // 字体大小 + {align: []}, // 对齐方式 + {list: 'ordered'}, // 有序列表 + {list: 'bullet'}, // 无序列表 + {script: 'sub'}, // 下标 x2 + {script: 'super'}, // 上标 平方 (x2) + { 'color': [] }, // 字体颜色 + { 'background': [] }, // 背景色 + {header: [1,2,3,4,5,false]}, // H1,H2 ... + 'blockquote', // 文件左边加一个边框样式 + 'code-block', // 块内容 + 'link', // 链接 + 'image', // 图片 + 'video', // 视频 + 'formula', // 数学公式 + 'clean' // 清除 + ] + ``` + + + ### 使用 + + ```` + 编辑模式是放不大图片的 + import QuillForEditor from 'xxx'; + + // 指定需要显示的工具栏信息, 不指定加载全部 + const options = [ + + ]; + + /** + * @description 获取编辑器返回的内容 + * @params [Object] value 编辑器内容 + */ + const handleCtxChange = (value) => { + // 编辑器内容非空判断 + const _text = quill.getText(); + const reg = /^[\s\S]*.*[^\s][\s\S]*$/; + if (!reg.test(_text)) { + // 处理编辑器内容为空 + } else { + // 提交到后台的内容需要处理一下; + value = JSON.stringify(value) + } + } + + + + + ```` + diff --git a/public/react/src/forge/quillForEditor/deepEqual.js b/public/react/src/forge/quillForEditor/deepEqual.js new file mode 100644 index 000000000..6f2b276bf --- /dev/null +++ b/public/react/src/forge/quillForEditor/deepEqual.js @@ -0,0 +1,47 @@ +function deepEqual (prev, current) { + if (prev === current) { // 基本类型比较,值,类型都相同 或者同为 null or undefined + return true; + } + + if ((!prev && current) + || (prev && !current) + || (!prev && !current) + ) { + return false; + } + + if (Array.isArray(prev)) { + if (!Array.isArray(current)) return false; + if (prev.length !== current.length) return false; + + for (let i = 0; i < prev.length; i++) { + if (!deepEqual(current[i], prev[i])) { + return false; + } + } + return true; + } + + if (typeof current === 'object') { + if (typeof prev !== 'object') return false; + const prevKeys = Object.keys(prev); + const curKeys = Object.keys(current); + + if (prevKeys.length !== curKeys.length) return false; + + prevKeys.sort(); + curKeys.sort(); + + for (let i = 0; i < prevKeys.length; i++) { + if (prevKeys[i] !== curKeys[i]) return false; + const key = prevKeys[i]; + if (!deepEqual(prev[key], current[key])) return false; + } + + return true; + } + + return false; +} + +export default deepEqual; diff --git a/public/react/src/forge/quillForEditor/index.js b/public/react/src/forge/quillForEditor/index.js new file mode 100644 index 000000000..012743141 --- /dev/null +++ b/public/react/src/forge/quillForEditor/index.js @@ -0,0 +1,280 @@ +/* + * @Description: quill 编辑器 + * @Author: tangjiang + * @Github: + * @Date: 2019-12-18 08:49:30 + * @LastEditors : tangjiang + * @LastEditTime : 2020-02-05 11:23:03 + */ +import './index.scss'; +import 'quill/dist/quill.core.css'; // 核心样式 +import 'quill/dist/quill.snow.css'; // 有工具栏 +import 'quill/dist/quill.bubble.css'; // 无工具栏 +import 'katex/dist/katex.min.css'; // katex 表达式样式 +import React, { useState, useRef, useEffect } from 'react'; +import Quill from 'quill'; +import katex from 'katex'; +import deepEqual from './deepEqual.js' +import { fetchUploadImage } from '../../services/ojService.js'; +import { getImageUrl } from 'educoder' +import ImageBlot from './ImageBlot'; +import FillBlot from './FillBlot'; +const Size = Quill.import('attributors/style/size'); +const Font = Quill.import('formats/font'); +// const Color = Quill.import('attributes/style/color'); +Size.whitelist = ['12px', '14px', '16px', '18px', '20px', false]; +Font.whitelist = ['SimSun', 'SimHei','Microsoft-YaHei','KaiTi','FangSong','Arial','Times-New-Roman','sans-serif']; + +window.Quill = Quill; +window.katex = katex; +Quill.register(ImageBlot); +Quill.register(Size); +Quill.register(Font, true); +// Quill.register({'modules/toolbar': Toolbar}); +Quill.register({ + 'formats/fill': FillBlot +}); +// Quill.register(Color); + + +function QuillForEditor ({ + placeholder, + readOnly, + autoFocus = false, + options, + value, + imgAttrs = {}, // 指定图片的宽高 + style = {}, + wrapStyle = {}, + showUploadImage, + onContentChange, + addFill, // 点击填空成功的回调 + deleteFill // 删除填空,返回删除的下标 + // getQuillContent +}) { + // toolbar 默认值 + const defaultConfig = [ + 'bold', 'italic', 'underline', + {size: ['12px', '14px', '16px', '18px', '20px']}, + {align: []}, {list: 'ordered'}, {list: 'bullet'}, // 列表 + {script: 'sub'}, {script: 'super'}, + { 'color': [] }, { 'background': [] }, + {header: [1,2,3,4,5,false]}, + 'blockquote', 'code-block', + 'link', 'image', 'video', + 'formula', + 'clean' + ]; + + const editorRef = useRef(null); + // quill 实例 + const [quill, setQuill] = useState(null); + const [selection, setSelection] = useState(null); + const [fillCount, setFillCount] = useState(0); + const [quillCtx, setQuillCtx] = useState({}); + + // 文本内容变化时 + const handleOnChange = content => { + // getQuillContent && getQuillContent(quill); + onContentChange && onContentChange(content, quill); + }; + + const renderOptions = options || defaultConfig; + + const bindings = { + tab: { + key: 9, + handler: function () { + console.log('调用了tab=====>>>>'); + } + }, + backspace: { + key: 'Backspace', + /** + * @param {*} range + * { index, // 删除元素的位置 + * length // 删除元素的个数, 当删除一个时, length=0, 其它等于删除的元素的个数 + * } + * @param {*} context 上下文 + */ + handler: function (range, context) { + /** + * index: 删除元素的位置 + * length: 删除元素的个数 + */ + const {index, length} = range; + const _start = length === 0 ? index - 1 : index; + const _length = length || 1; + let delCtx = this.quill.getText(_start, _length); // 删除的元素 + // aa + const reg = /▁/g; + const delArrs = delCtx.match(reg); + if (delArrs) { + const r = window.confirm('确定要删除吗?'); + if (r) { + let leaveCtx; // 获取删除元素之前的内容 + if (length === 0) { + leaveCtx = this.quill.getText(0, index - 1); + } else { + leaveCtx = this.quill.getText(0, index); + } + const leaveArrs = leaveCtx.match(reg); + const leaveLen = (leaveArrs || []).length; + let delIndexs = []; + // 获取删除元素的下标 + delArrs.forEach((item, i) => { + leaveLen === 0 ? delIndexs.push(i) : delIndexs.push(leaveLen + i); + }); + deleteFill && deleteFill(delIndexs); // 调用删除回调, 返回删除的元素下标[] + return true + } else { + return false; + } + } + return true; + } + } + }; + // quill 配置信息 + const quillOption = { + modules: { + toolbar: renderOptions, + keyboard: { + bindings: bindings + } + // toolbar: { + // container: renderOptions + // } + }, + readOnly, + placeholder, + theme: readOnly ? 'bubble' : 'snow', + }; + + + useEffect(() => { + + const quillNode = document.createElement('div'); + editorRef.current.appendChild(quillNode); + const _quill = new Quill(editorRef.current, quillOption); + + setQuill(_quill); + // 处理图片上传功能 + _quill.getModule('toolbar').addHandler('image', (e) => { + const input = document.createElement('input'); + input.setAttribute('type', 'file'); + input.setAttribute('accept', 'image/*'); + input.click(); + + input.onchange = async (e) => { + const file = input.files[0]; // 获取文件信息 + const formData = new FormData(); + formData.append('file', file); + + const range = _quill.getSelection(true); + let fileUrl = ''; // 保存上传成功后图片的url + // 上传文件 + const result = await fetchUploadImage(formData); + // 获取上传图片的url + if (result.data && result.data.id) { + fileUrl = getImageUrl(`api/attachments/${result.data.id}`); + } + // 根据id获取文件路径 + const { width, height } = imgAttrs; + // console.log('上传图片的url:', fileUrl); + if (fileUrl) { + _quill.insertEmbed(range.index, 'image', { + url: fileUrl, + alt: '图片信息', + onClick: showUploadImage, + width, + height + }); + } + } + }); + // 处理填空 + _quill.getModule('toolbar').addHandler('fill', (e) => { + // alert(1111); + setFillCount(fillCount + 1); + const range = _quill.getSelection(true); + _quill.insertText(range.index, '▁'); + addFill && addFill(); // 调用添加回调 + }); + }, []); + + // 设置值 + useEffect(() => { + if (!quill) return + + const previous = quill.getContents() + + if (value && value.hasOwnProperty('ops')) { + // console.log(value.ops); + const ops = value.ops || []; + ops.forEach((item, i) => { + if (item.insert['image']) { + item.insert['image'] = Object.assign({}, item.insert['image'], {style: { cursor: 'pointer' }, onclick: (url) => showUploadImage(url)}); + } + }); + } + + const current = value + if (!deepEqual(previous, current)) { + setSelection(quill.getSelection()) + if (typeof value === 'string' && value) { + // debugger + quill.clipboard.dangerouslyPasteHTML(value, 'api'); + if (autoFocus) { + quill.focus(); + } else { + quill.blur(); + } + } else { + quill.setContents(value) + if (autoFocus) quill.focus(); + } + } + }, [quill, value, setQuill, autoFocus]); + + // 清除选择区域 + useEffect(() => { + if (quill && selection) { + quill.setSelection(selection) + setSelection(null) + } + }, [quill, selection, setSelection]); + + // 设置placeholder值 + useEffect(() => { + if (!quill || !quill.root) return; + quill.root.dataset.placeholder = placeholder; + }, [quill, placeholder]); + + // 处理内容变化 + useEffect(() => { + if (!quill) return; + if (typeof handleOnChange !== 'function') return; + let handler; + quill.on( + 'text-change', + (handler = (delta, oldDelta, source) => { + const _ctx = quill.getContents(); + setQuillCtx(_ctx); + handleOnChange(quill.getContents()); // getContents: 检索编辑器内容 + }) + ); + return () => { + quill.off('text-change', handler); + } + }, [quill, handleOnChange]); + + // 返回结果 + return ( +
+
+
+ ); +} + +export default QuillForEditor; diff --git a/public/react/src/forge/quillForEditor/index.scss b/public/react/src/forge/quillForEditor/index.scss new file mode 100644 index 000000000..dd4eb0349 --- /dev/null +++ b/public/react/src/forge/quillForEditor/index.scss @@ -0,0 +1,132 @@ +.quill_editor_for_react_area{ + // background: #fff; + // margin: 0 15px; + .ql-editing{ + left: 0 !important; + } + .ql-editor{ + img{ + cursor: pointer; + } + } + .ql-snow .ql-picker.ql-size .ql-picker-label[data-value="12px"]::before, + .ql-snow .ql-picker.ql-size .ql-picker-item[data-value="12px"]::before { + content: '12px'; + font-size: 12px; + } + .ql-snow .ql-picker.ql-size .ql-picker-label[data-value="14px"]::before, + .ql-snow .ql-picker.ql-size .ql-picker-item[data-value="14px"]::before { + content: '14px'; + font-size: 14px; + } + .ql-snow .ql-picker.ql-size .ql-picker-label[data-value="16px"]::before, + .ql-snow .ql-picker.ql-size .ql-picker-item[data-value="16px"]::before { + content: '16px'; + font-size: 16px; + } + .ql-snow .ql-picker.ql-size .ql-picker-label[data-value="18px"]::before, + .ql-snow .ql-picker.ql-size .ql-picker-item[data-value="18px"]::before { + content: '18px'; + font-size: 18px; + } + .ql-snow .ql-picker.ql-size .ql-picker-label[data-value="20px"]::before, + .ql-snow .ql-picker.ql-size .ql-picker-item[data-value="20px"]::before { + content: '20px'; + font-size: 20px; + } + //默认的样式 + .ql-snow .ql-picker.ql-size .ql-picker-label::before, + .ql-snow .ql-picker.ql-size .ql-picker-item::before { + content: '14px'; + font-size: 14px; + } + + .ql-snow .ql-picker.ql-font .ql-picker-label[data-value=SimSun]::before, + .ql-snow .ql-picker.ql-font .ql-picker-item[data-value=SimSun]::before { + content: "宋体"; + font-family: "SimSun"; + } + .ql-snow .ql-picker.ql-font .ql-picker-label[data-value=SimHei]::before, + .ql-snow .ql-picker.ql-font .ql-picker-item[data-value=SimHei]::before { + content: "黑体"; + font-family: "SimHei"; + } + .ql-snow .ql-picker.ql-font .ql-picker-label[data-value=Microsoft-YaHei]::before, + .ql-snow .ql-picker.ql-font .ql-picker-item[data-value=Microsoft-YaHei]::before { + content: "微软雅黑"; + font-family: "Microsoft YaHei"; + } + .ql-snow .ql-picker.ql-font .ql-picker-label[data-value=KaiTi]::before, + .ql-snow .ql-picker.ql-font .ql-picker-item[data-value=KaiTi]::before { + content: "楷体"; + font-family: "KaiTi"; + } + .ql-snow .ql-picker.ql-font .ql-picker-label[data-value=FangSong]::before, + .ql-snow .ql-picker.ql-font .ql-picker-item[data-value=FangSong]::before { + content: "仿宋"; + font-family: "FangSong"; + } + .ql-snow .ql-picker.ql-font .ql-picker-label[data-value=Arial]::before, + .ql-snow .ql-picker.ql-font .ql-picker-item[data-value=Arial]::before { + content: "Arial"; + font-family: "Arial"; + } + .ql-snow .ql-picker.ql-font .ql-picker-label[data-value=Times-New-Roman]::before, + .ql-snow .ql-picker.ql-font .ql-picker-item[data-value=Times-New-Roman]::before { + content: "Times New Roman"; + font-family: "Times New Roman"; + } + .ql-snow .ql-picker.ql-font .ql-picker-label[data-value=sans-serif]::before, + .ql-snow .ql-picker.ql-font .ql-picker-item[data-value=sans-serif]::before { + content: "sans-serif"; + font-family: "sans-serif"; + } + + .ql-font-SimSun { + font-family: "SimSun"; + } + .ql-font-SimHei { + font-family: "SimHei"; + } + .ql-font-Microsoft-YaHei { + font-family: "Microsoft YaHei"; + } + .ql-font-KaiTi { + font-family: "KaiTi"; + } + .ql-font-FangSong { + font-family: "FangSong"; + } + .ql-font-Arial { + font-family: "Arial"; + } + .ql-font-Times-New-Roman { + font-family: "Times New Roman"; + } + .ql-font-sans-serif { + font-family: "sans-serif"; + } + + .ql-snow .ql-picker.ql-font .ql-picker-label::before, + .ql-snow .ql-picker.ql-font .ql-picker-item::before { + content: "微软雅黑"; + font-family: "Microsoft YaHei"; + } + + // 填空图标 + .ql-snow .ql-fill{ + display: inline-block; + position: relative; + color: #05101A; + // font-size: 18px; + vertical-align: top; + &::before{ + position: absolute; + left: 50%; + top: -1px; + content: '\e709'; + font-family: 'iconfont'; + margin-left: -7px; + } + } +} \ No newline at end of file