|
@@ -70,38 +70,38 @@ Quill.register(SizeAttributor, true);
|
|
|
Quill.register(Quill.import('attributors/style/align') as any, true);
|
|
Quill.register(Quill.import('attributors/style/align') as any, true);
|
|
|
Quill.register(Quill.import('attributors/style/direction') as any, true);
|
|
Quill.register(Quill.import('attributors/style/direction') as any, true);
|
|
|
|
|
|
|
|
-// 缩进改为内联样式:Quill默认缩进输出class(ql-indent-N),APP端渲染器只认内联样式,
|
|
|
|
|
-// 后台加的缩进到APP上会全部丢失(正文贴边、无法自行调整边距)。
|
|
|
|
|
-// 这里用padding-left承载缩进层级,编辑器与APP看到的效果一致
|
|
|
|
|
-const INDENT_EM = 2; // 每级缩进对应的em数
|
|
|
|
|
-const INDENT_MAX = 8; // 与Quill默认缩进层级上限保持一致
|
|
|
|
|
-const StyleAttributorBase: any = (Quill.import('parchment') as any).StyleAttributor;
|
|
|
|
|
-class IndentStyleAttributor extends StyleAttributorBase {
|
|
|
|
|
- add(node: HTMLElement, value: any) {
|
|
|
|
|
- // 工具栏按钮和Tab键传的是'+1'/'-1',先换算成绝对层级
|
|
|
|
|
- if (value === '+1' || value === '-1') {
|
|
|
|
|
- const cur = this.value(node) || 0;
|
|
|
|
|
- value = value === '+1' ? cur + 1 : cur - 1;
|
|
|
|
|
|
|
+// 缩进改为"行首不间断空格"(首行缩进):曾用内联padding-left,但APP端渲染器把
|
|
|
|
|
+// padding-left作用到整段(所有行整体平移),不符合中文首行缩进观感。
|
|
|
|
|
+// U+00A0任何渲染器都不折叠、且只影响首行,该方案APP端零改动。
|
|
|
|
|
+// 缩进按钮不再走Quill的indent格式(会输出ql-indent class),实现见toolbar handlers.indent
|
|
|
|
|
+const INDENT_SPACES = 2; // 每级缩进的不间断空格数(即首行缩进2字符)
|
|
|
|
|
+const INDENT_MAX = 8; // 缩进层级上限
|
|
|
|
|
+const NBSP = '\u00a0';
|
|
|
|
|
+
|
|
|
|
|
+// 增加/减少缩进:对选区每一行行首插入或移除一级不间断空格(首行缩进,APP端原样显示)
|
|
|
|
|
+function applyIndent(direction: string) {
|
|
|
|
|
+ const quill = toRaw(quillEditorRef.value)?.getQuill();
|
|
|
|
|
+ if (!quill) return;
|
|
|
|
|
+ const range = quill.getSelection();
|
|
|
|
|
+ if (!range) return;
|
|
|
|
|
+ const lines = quill.getLines(range);
|
|
|
|
|
+ if (!lines || !lines.length) return;
|
|
|
|
|
+ // 从后往前处理:前行插入/删除文本会让后行的下标移位
|
|
|
|
|
+ for (let i = lines.length - 1; i >= 0; i--) {
|
|
|
|
|
+ const idx = (lines[i] as any).offset(quill.scroll);
|
|
|
|
|
+ const head = quill.getText(idx, INDENT_MAX * INDENT_SPACES);
|
|
|
|
|
+ let level = 0;
|
|
|
|
|
+ while (level < INDENT_MAX && head.startsWith(NBSP.repeat((level + 1) * INDENT_SPACES))) level++;
|
|
|
|
|
+ if (direction === '+1') {
|
|
|
|
|
+ if (level < INDENT_MAX) quill.insertText(idx, NBSP.repeat(INDENT_SPACES), 'user');
|
|
|
|
|
+ } else if (level > 0) {
|
|
|
|
|
+ quill.deleteText(idx, INDENT_SPACES, 'user');
|
|
|
|
|
+ } else if (head.startsWith(NBSP)) {
|
|
|
|
|
+ // 行首是单个不间断空格(手工敲的)时也允许退掉
|
|
|
|
|
+ quill.deleteText(idx, 1, 'user');
|
|
|
}
|
|
}
|
|
|
- const level = Math.min(Math.max(parseInt(value, 10) || 0, 0), INDENT_MAX);
|
|
|
|
|
- if (level <= 0) return this.remove(node);
|
|
|
|
|
- node.style.paddingLeft = `${level * INDENT_EM}em`;
|
|
|
|
|
- return true;
|
|
|
|
|
- }
|
|
|
|
|
- value(node: HTMLElement) {
|
|
|
|
|
- const raw = node.style ? node.style.paddingLeft : '';
|
|
|
|
|
- if (!raw) return undefined;
|
|
|
|
|
- const n = parseFloat(raw);
|
|
|
|
|
- if (!n || n <= 0) return undefined;
|
|
|
|
|
- const level = Math.round(raw.indexOf('px') >= 0 ? n / (INDENT_EM * 16) : n / INDENT_EM);
|
|
|
|
|
- return level > 0 ? level : undefined;
|
|
|
|
|
- }
|
|
|
|
|
- remove(node: HTMLElement) {
|
|
|
|
|
- node.style.paddingLeft = '';
|
|
|
|
|
- return true;
|
|
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
-Quill.register(new IndentStyleAttributor('indent', 'padding-left', { scope: (Quill.import('parchment') as any).Scope.BLOCK }), true);
|
|
|
|
|
|
|
|
|
|
// 视频用 <video> 标签插入:Quill默认video格式是iframe嵌入,APP端渲染器对iframe兼容差;
|
|
// 视频用 <video> 标签插入:Quill默认video格式是iframe嵌入,APP端渲染器对iframe兼容差;
|
|
|
// video标签在WebView原生可播、flutter_html等组件渲染也可扩展支持,兼容性严格优于iframe
|
|
// video标签在WebView原生可播、flutter_html等组件渲染也可扩展支持,兼容性严格优于iframe
|
|
@@ -158,8 +158,8 @@ const TOOLBAR_TIPS: [string, string][] = [
|
|
|
['button.ql-code-block', '代码块:选中内容设为代码块'],
|
|
['button.ql-code-block', '代码块:选中内容设为代码块'],
|
|
|
['button.ql-list[value="ordered"]', '有序列表:选中内容加数字编号'],
|
|
['button.ql-list[value="ordered"]', '有序列表:选中内容加数字编号'],
|
|
|
['button.ql-list[value="bullet"]', '无序列表:选中内容加圆点符号'],
|
|
['button.ql-list[value="bullet"]', '无序列表:选中内容加圆点符号'],
|
|
|
- ['button.ql-indent[value="-1"]', '减少缩进:降低段落缩进层级(APP端同步生效)'],
|
|
|
|
|
- ['button.ql-indent[value="+1"]', '增加缩进:提高段落缩进层级(APP端同步生效)'],
|
|
|
|
|
|
|
+ ['button.ql-indent[value="-1"]', '减少缩进:减少段落首行缩进(APP端同步生效)'],
|
|
|
|
|
+ ['button.ql-indent[value="+1"]', '增加缩进:段落首行缩进2字符(APP端同步生效)'],
|
|
|
['.ql-picker.ql-size .ql-picker-label', '字体大小:设置选中文字的字号'],
|
|
['.ql-picker.ql-size .ql-picker-label', '字体大小:设置选中文字的字号'],
|
|
|
['.ql-picker.ql-header .ql-picker-label', '标题:切换段落标题级别'],
|
|
['.ql-picker.ql-header .ql-picker-label', '标题:切换段落标题级别'],
|
|
|
['.ql-picker.ql-color .ql-picker-label', '字体颜色:设置选中文字的颜色'],
|
|
['.ql-picker.ql-color .ql-picker-label', '字体颜色:设置选中文字的颜色'],
|
|
@@ -315,6 +315,10 @@ const options = ref<any>({
|
|
|
videoDialog.uploadedName = '';
|
|
videoDialog.uploadedName = '';
|
|
|
videoDialog.mode = 'url';
|
|
videoDialog.mode = 'url';
|
|
|
videoDialog.visible = true;
|
|
videoDialog.visible = true;
|
|
|
|
|
+ },
|
|
|
|
|
+ // 缩进按钮:行首不间断空格实现首行缩进,不用Quill的indent格式(会输出ql-indent class,APP不认识)
|
|
|
|
|
+ indent: (value: string) => {
|
|
|
|
|
+ applyIndent(value);
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
},
|
|
},
|
|
@@ -346,7 +350,7 @@ watch(
|
|
|
() => props.modelValue,
|
|
() => props.modelValue,
|
|
|
(v: string) => {
|
|
(v: string) => {
|
|
|
if (v === lastEmitted) return;
|
|
if (v === lastEmitted) return;
|
|
|
- // 旧数据缩进存的是Quill class(ql-indent-N),先转成内联padding-left;
|
|
|
|
|
|
|
+ // 旧数据缩进存的是Quill class(ql-indent-N)或上一版内联padding-left,统一转成行首不间断空格(首行缩进);
|
|
|
// 再把手工敲的空格/Tab统一成不间断空格,保证不编辑直接保存也能在APP生效。
|
|
// 再把手工敲的空格/Tab统一成不间断空格,保证不编辑直接保存也能在APP生效。
|
|
|
// 转换在watch渲染期同步执行,一旦对畸形/超大内容抛错会连累整个Editor挂载失败、
|
|
// 转换在watch渲染期同步执行,一旦对畸形/超大内容抛错会连累整个Editor挂载失败、
|
|
|
// 弹窗打不开(表现为点"修改"没反应),所以异常时降级为原始内容,绝不阻断渲染
|
|
// 弹窗打不开(表现为点"修改"没反应),所以异常时降级为原始内容,绝不阻断渲染
|
|
@@ -566,30 +570,50 @@ function handleTextChange() {
|
|
|
emit('update:modelValue', lastEmitted);
|
|
emit('update:modelValue', lastEmitted);
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
-// 历史数据兼容:旧版缩进是Quill class(ql-indent-N),加载时转成内联padding-left
|
|
|
|
|
|
|
+// 历史数据兼容:旧缩进(Quill class ql-indent-N / 上一版内联padding-left)转成行首不间断空格;
|
|
|
|
|
+// 幂等:转换后标记被移除,再次进入不会重复叠加缩进
|
|
|
function migrateIndentHtml(html: string): string {
|
|
function migrateIndentHtml(html: string): string {
|
|
|
- if (!html || html.indexOf('ql-indent-') < 0) return html;
|
|
|
|
|
|
|
+ if (!html || (html.indexOf('ql-indent-') < 0 && html.toLowerCase().indexOf('padding-left') < 0)) return html;
|
|
|
return html.replace(/<([a-zA-Z][\w-]*)((?:[^>"']|"[^"]*"|'[^']*')*)>/g, (tag: string, name: string, attrs: string) => {
|
|
return html.replace(/<([a-zA-Z][\w-]*)((?:[^>"']|"[^"]*"|'[^']*')*)>/g, (tag: string, name: string, attrs: string) => {
|
|
|
|
|
+ if (!/^(?:p|div|h[1-6]|li|blockquote|pre)$/i.test(name)) return tag;
|
|
|
|
|
+ let level = 0;
|
|
|
|
|
+ let newAttrs = attrs;
|
|
|
const cm = /\sclass\s*=\s*"([^"]*)"/i.exec(attrs) || /\sclass\s*=\s*'([^']*)'/i.exec(attrs);
|
|
const cm = /\sclass\s*=\s*"([^"]*)"/i.exec(attrs) || /\sclass\s*=\s*'([^']*)'/i.exec(attrs);
|
|
|
- if (!cm) return tag;
|
|
|
|
|
- const im = /ql-indent-(\d+)/.exec(cm[1]);
|
|
|
|
|
- if (!im) return tag;
|
|
|
|
|
- const level = Math.min(parseInt(im[1], 10) || 0, INDENT_MAX);
|
|
|
|
|
- if (level < 1) return tag;
|
|
|
|
|
- const restClass = cm[1].replace(/ql-indent-\d+/g, '').replace(/\s+/g, ' ').trim();
|
|
|
|
|
- let newAttrs = attrs.replace(cm[0], restClass ? ` class="${restClass}"` : '');
|
|
|
|
|
- const pad = `padding-left: ${level * INDENT_EM}em;`;
|
|
|
|
|
- const sm = /\sstyle\s*=\s*"([^"]*)"/i.exec(newAttrs);
|
|
|
|
|
|
|
+ if (cm) {
|
|
|
|
|
+ const im = /ql-indent-(\d+)/.exec(cm[1]);
|
|
|
|
|
+ if (im) {
|
|
|
|
|
+ level = Math.min(parseInt(im[1], 10) || 0, INDENT_MAX);
|
|
|
|
|
+ const restClass = cm[1]
|
|
|
|
|
+ .replace(/ql-indent-\d+/g, '')
|
|
|
|
|
+ .replace(/\s+/g, ' ')
|
|
|
|
|
+ .trim();
|
|
|
|
|
+ newAttrs = newAttrs.replace(cm[0], restClass ? ` class="${restClass}"` : '');
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ const sm = /\sstyle\s*=\s*"([^"]*)"/i.exec(newAttrs) || /\sstyle\s*=\s*'([^']*)'/i.exec(newAttrs);
|
|
|
if (sm) {
|
|
if (sm) {
|
|
|
- newAttrs = newAttrs.replace(sm[0], ` style="${pad}${sm[1]}"`);
|
|
|
|
|
- } else {
|
|
|
|
|
- newAttrs += ` style="${pad}"`;
|
|
|
|
|
|
|
+ const pm = /padding-left\s*:\s*([^;"]+)/i.exec(sm[1]);
|
|
|
|
|
+ if (pm) {
|
|
|
|
|
+ const raw = pm[1].trim();
|
|
|
|
|
+ const n = parseFloat(raw);
|
|
|
|
|
+ if (n > 0) {
|
|
|
|
|
+ const lv = Math.round(raw.toLowerCase().indexOf('px') >= 0 ? n / (INDENT_SPACES * 16) : n / INDENT_SPACES);
|
|
|
|
|
+ level = Math.max(level, Math.min(lv, INDENT_MAX));
|
|
|
|
|
+ }
|
|
|
|
|
+ const restStyle = sm[1]
|
|
|
|
|
+ .replace(/padding-left\s*:\s*[^;"]+;?/gi, '')
|
|
|
|
|
+ .replace(/\s+/g, ' ')
|
|
|
|
|
+ .replace(/^;+/, '')
|
|
|
|
|
+ .trim();
|
|
|
|
|
+ newAttrs = newAttrs.replace(sm[0], restStyle ? ` style="${restStyle}"` : '');
|
|
|
|
|
+ }
|
|
|
}
|
|
}
|
|
|
- return `<${name}${newAttrs}>`;
|
|
|
|
|
|
|
+ if (level < 1) return tag;
|
|
|
|
|
+ return `<${name}${newAttrs}>` + NBSP.repeat(level * INDENT_SPACES);
|
|
|
});
|
|
});
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
-// 读缩进层级:兼容内联padding-left与旧版ql-indent-N class
|
|
|
|
|
|
|
+// 读缩进层级:兼容内联padding-left与旧版ql-indent-N class(粘贴内容用,转成行首不间断空格)
|
|
|
function readIndentLevel(el: HTMLElement): number {
|
|
function readIndentLevel(el: HTMLElement): number {
|
|
|
let level = 0;
|
|
let level = 0;
|
|
|
const cm = /ql-indent-(\d+)/.exec(el.getAttribute('class') || '');
|
|
const cm = /ql-indent-(\d+)/.exec(el.getAttribute('class') || '');
|
|
@@ -597,20 +621,18 @@ function readIndentLevel(el: HTMLElement): number {
|
|
|
const pl = el.style.paddingLeft;
|
|
const pl = el.style.paddingLeft;
|
|
|
if (pl) {
|
|
if (pl) {
|
|
|
const n = parseFloat(pl);
|
|
const n = parseFloat(pl);
|
|
|
- if (n > 0) level = Math.max(level, Math.round(pl.indexOf('px') >= 0 ? n / (INDENT_EM * 16) : n / INDENT_EM));
|
|
|
|
|
|
|
+ if (n > 0) level = Math.max(level, Math.round(pl.indexOf('px') >= 0 ? n / (INDENT_SPACES * 16) : n / INDENT_SPACES));
|
|
|
}
|
|
}
|
|
|
return level;
|
|
return level;
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
-// 插入一个带块级格式的换行符:Quill的缩进/对齐/标题/列表都是"行"格式,只认换行符上的属性
|
|
|
|
|
|
|
+// 插入一个带块级格式的换行符:Quill的对齐/标题/列表都是"行"格式,只认换行符上的属性
|
|
|
function insertBlockBreak(delta: Delta, el: HTMLElement, listType: 'ordered' | 'bullet' | null) {
|
|
function insertBlockBreak(delta: Delta, el: HTMLElement, listType: 'ordered' | 'bullet' | null) {
|
|
|
const fmt: { [key: string]: any } = {};
|
|
const fmt: { [key: string]: any } = {};
|
|
|
const hm = /^h([1-6])$/.exec(el.tagName.toLowerCase());
|
|
const hm = /^h([1-6])$/.exec(el.tagName.toLowerCase());
|
|
|
if (hm) fmt.header = parseInt(hm[1], 10);
|
|
if (hm) fmt.header = parseInt(hm[1], 10);
|
|
|
if (el.style.textAlign) fmt.align = el.style.textAlign;
|
|
if (el.style.textAlign) fmt.align = el.style.textAlign;
|
|
|
if (listType) fmt.list = listType;
|
|
if (listType) fmt.list = listType;
|
|
|
- const level = readIndentLevel(el);
|
|
|
|
|
- if (level > 0) fmt.indent = level;
|
|
|
|
|
delta.insert('\n', Object.keys(fmt).length ? fmt : undefined);
|
|
delta.insert('\n', Object.keys(fmt).length ? fmt : undefined);
|
|
|
}
|
|
}
|
|
|
|
|
|
|
@@ -682,6 +704,14 @@ function buildDeltaWithListSupport(
|
|
|
// 是否是块级元素(需要换行)
|
|
// 是否是块级元素(需要换行)
|
|
|
const isBlock = ['p', 'div', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'li', 'br'].includes(tagName);
|
|
const isBlock = ['p', 'div', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'li', 'br'].includes(tagName);
|
|
|
|
|
|
|
|
|
|
+ // 粘贴内容里的旧缩进(padding-left/ql-indent class)转成行首不间断空格,与编辑器缩进按钮同一方案
|
|
|
|
|
+ if (isBlock && tagName !== 'br') {
|
|
|
|
|
+ const indentLevel = readIndentLevel(el);
|
|
|
|
|
+ if (indentLevel > 0) {
|
|
|
|
|
+ delta.insert(NBSP.repeat(indentLevel * INDENT_SPACES));
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
// 遍历子节点
|
|
// 遍历子节点
|
|
|
for (let i = 0; i < el.childNodes.length; i++) {
|
|
for (let i = 0; i < el.childNodes.length; i++) {
|
|
|
const child = el.childNodes[i];
|
|
const child = el.childNodes[i];
|