|
|
@@ -21,7 +21,7 @@
|
|
|
content-type="html"
|
|
|
:options="options"
|
|
|
:style="styles"
|
|
|
- @text-change="(e: any) => $emit('update:modelValue', content)"
|
|
|
+ @text-change="handleTextChange"
|
|
|
/>
|
|
|
</div>
|
|
|
<!-- 视频插入弹窗:网页链接 / 本地上传 二选一 -->
|
|
|
@@ -70,6 +70,39 @@ Quill.register(SizeAttributor, true);
|
|
|
Quill.register(Quill.import('attributors/style/align') 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;
|
|
|
+ }
|
|
|
+ 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标签在WebView原生可播、flutter_html等组件渲染也可扩展支持,兼容性严格优于iframe
|
|
|
const BlockEmbed = Quill.import('blots/block/embed') as any;
|
|
|
@@ -125,8 +158,8 @@ const TOOLBAR_TIPS: [string, string][] = [
|
|
|
['button.ql-code-block', '代码块:选中内容设为代码块'],
|
|
|
['button.ql-list[value="ordered"]', '有序列表:选中内容加数字编号'],
|
|
|
['button.ql-list[value="bullet"]', '无序列表:选中内容加圆点符号'],
|
|
|
- ['button.ql-indent[value="-1"]', '减少缩进:降低段落缩进层级'],
|
|
|
- ['button.ql-indent[value="+1"]', '增加缩进:提高段落缩进层级'],
|
|
|
+ ['button.ql-indent[value="-1"]', '减少缩进:降低段落缩进层级(APP端同步生效)'],
|
|
|
+ ['button.ql-indent[value="+1"]', '增加缩进:提高段落缩进层级(APP端同步生效)'],
|
|
|
['.ql-picker.ql-size .ql-picker-label', '字体大小:设置选中文字的字号'],
|
|
|
['.ql-picker.ql-header .ql-picker-label', '标题:切换段落标题级别'],
|
|
|
['.ql-picker.ql-color .ql-picker-label', '字体颜色:设置选中文字的颜色'],
|
|
|
@@ -200,7 +233,7 @@ function bindToolbarTips() {
|
|
|
}
|
|
|
bindToolbarTips();
|
|
|
|
|
|
-defineEmits(['update:modelValue']);
|
|
|
+const emit = defineEmits(['update:modelValue']);
|
|
|
|
|
|
const props = defineProps({
|
|
|
/* 编辑器的内容 */
|
|
|
@@ -306,11 +339,23 @@ const styles = computed(() => {
|
|
|
});
|
|
|
|
|
|
const content = ref('');
|
|
|
+// 记录最后一次发给父组件的HTML,用于识别自己输出的"回声":回声不能回写编辑器,
|
|
|
+// 否则敲空格时每次按键都会整篇重建内容(光标跳动、中文输入法被打断)
|
|
|
+let lastEmitted = '';
|
|
|
watch(
|
|
|
() => props.modelValue,
|
|
|
(v: string) => {
|
|
|
- if (v !== content.value) {
|
|
|
- content.value = v || '<p></p>';
|
|
|
+ if (v === lastEmitted) return;
|
|
|
+ // 旧数据缩进存的是Quill class(ql-indent-N),先转成内联padding-left;
|
|
|
+ // 再把手工敲的空格/Tab统一成不间断空格,保证不编辑直接保存也能在APP生效
|
|
|
+ const next = stabilizeSpacesHtml(migrateIndentHtml(v || ''));
|
|
|
+ if (next !== content.value) {
|
|
|
+ content.value = next || '<p></p>';
|
|
|
+ // 转换结果同步回父组件表单,避免未编辑直接保存时丢掉空格缩进
|
|
|
+ if (next) {
|
|
|
+ lastEmitted = next;
|
|
|
+ emit('update:modelValue', next);
|
|
|
+ }
|
|
|
}
|
|
|
},
|
|
|
{ immediate: true }
|
|
|
@@ -476,6 +521,84 @@ function cleanBackgroundOnly(parent: HTMLElement) {
|
|
|
});
|
|
|
}
|
|
|
|
|
|
+// 手工空格缩进也要在APP生效:HTML默认折叠行首和连续空格,编辑器靠white-space:pre-wrap
|
|
|
+// 才能原样显示,APP端渲染器按标准HTML折叠,用户自己敲的空格就失效了。
|
|
|
+// 输出时把每行行首空白、行内连续空白换成不间断空格(U+00A0),任何渲染器都原样保留
|
|
|
+const SPACE_RISK_RE = /(^|>)[ \t]|[ \t]{2,}/;
|
|
|
+function stabilizeSpacesHtml(html: string): string {
|
|
|
+ if (!html || !SPACE_RISK_RE.test(html)) return html;
|
|
|
+ let out = html;
|
|
|
+ // 1) 段落行首空白:中间允许隔内联开标签或已转换的不间断空格,多轮收敛
|
|
|
+ const leadRe = /(<(?:p|div|h[1-6]|li|blockquote|pre)\b[^>]*>(?:\u00a0|<(?!br\b|img\b|video\b|\/)[^>]+>)*)([ \t]+)/g;
|
|
|
+ for (let pass = 0; pass < 4; pass++) {
|
|
|
+ const next = out.replace(leadRe, (_m: string, head: string, run: string) => head + '\u00a0'.repeat(run.length));
|
|
|
+ if (next === out) break;
|
|
|
+ out = next;
|
|
|
+ }
|
|
|
+ // 2) 行内连续空格与Tab:按标签切段后只改文本段,不动属性里的空格
|
|
|
+ const parts = out.split(/(<[^>]*>)/);
|
|
|
+ for (let i = 0; i < parts.length; i += 2) {
|
|
|
+ if (parts[i] && /\t| {2,}/.test(parts[i])) {
|
|
|
+ parts[i] = parts[i].replace(/\t/g, '\u00a0\u00a0\u00a0\u00a0').replace(/ {2,}/g, (m) => '\u00a0'.repeat(m.length));
|
|
|
+ }
|
|
|
+ }
|
|
|
+ return parts.join('');
|
|
|
+}
|
|
|
+
|
|
|
+// 内容变化统一出口:先做"空格缩进APP化"转换再发给父组件
|
|
|
+function handleTextChange() {
|
|
|
+ lastEmitted = stabilizeSpacesHtml(content.value);
|
|
|
+ emit('update:modelValue', lastEmitted);
|
|
|
+}
|
|
|
+
|
|
|
+// 历史数据兼容:旧版缩进是Quill class(ql-indent-N),加载时转成内联padding-left
|
|
|
+function migrateIndentHtml(html: string): string {
|
|
|
+ if (!html || html.indexOf('ql-indent-') < 0) return html;
|
|
|
+ return html.replace(/<([a-zA-Z][\w-]*)((?:[^>"']|"[^"]*"|'[^']*')*)>/g, (tag: string, name: string, attrs: string) => {
|
|
|
+ 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 (sm) {
|
|
|
+ newAttrs = newAttrs.replace(sm[0], ` style="${pad}${sm[1]}"`);
|
|
|
+ } else {
|
|
|
+ newAttrs += ` style="${pad}"`;
|
|
|
+ }
|
|
|
+ return `<${name}${newAttrs}>`;
|
|
|
+ });
|
|
|
+}
|
|
|
+
|
|
|
+// 读缩进层级:兼容内联padding-left与旧版ql-indent-N class
|
|
|
+function readIndentLevel(el: HTMLElement): number {
|
|
|
+ let level = 0;
|
|
|
+ const cm = /ql-indent-(\d+)/.exec(el.getAttribute('class') || '');
|
|
|
+ if (cm) level = parseInt(cm[1], 10) || 0;
|
|
|
+ const pl = el.style.paddingLeft;
|
|
|
+ if (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));
|
|
|
+ }
|
|
|
+ return level;
|
|
|
+}
|
|
|
+
|
|
|
+// 插入一个带块级格式的换行符:Quill的缩进/对齐/标题/列表都是"行"格式,只认换行符上的属性
|
|
|
+function insertBlockBreak(delta: Delta, el: HTMLElement, listType: 'ordered' | 'bullet' | null) {
|
|
|
+ const fmt: { [key: string]: any } = {};
|
|
|
+ const hm = /^h([1-6])$/.exec(el.tagName.toLowerCase());
|
|
|
+ if (hm) fmt.header = parseInt(hm[1], 10);
|
|
|
+ if (el.style.textAlign) fmt.align = el.style.textAlign;
|
|
|
+ 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 构建
|
|
|
function buildDeltaWithListSupport(
|
|
|
node: Node,
|
|
|
@@ -548,19 +671,19 @@ function buildDeltaWithListSupport(
|
|
|
for (let i = 0; i < el.childNodes.length; i++) {
|
|
|
const child = el.childNodes[i];
|
|
|
|
|
|
- if (child.nodeType === Node.TEXT_NODE && isBlock && i === 0) {
|
|
|
- // 块级元素开头插入换行(除了第一个)
|
|
|
- if (delta.length() > 0) {
|
|
|
- delta.insert('\n');
|
|
|
- }
|
|
|
+ // <br>就是硬换行:直接换成带块级格式的换行符(Quill行内没有br)
|
|
|
+ if (child.nodeType === Node.ELEMENT_NODE && (child as HTMLElement).tagName === 'BR' && isBlock) {
|
|
|
+ insertBlockBreak(delta, el, listType);
|
|
|
+ continue;
|
|
|
}
|
|
|
|
|
|
buildDeltaWithListSupport(child, delta, currentFormat, newListType);
|
|
|
|
|
|
- // 如果是块级元素且不是最后一个,加换行
|
|
|
+ // 块级元素结尾的换行承载该段的块级格式(缩进/对齐/标题/列表);
|
|
|
+ // 开头不再补换行:结尾换行已起到分段作用,再补会把段间距翻倍
|
|
|
if (isBlock && i === el.childNodes.length - 1 && (el.nextSibling || el.parentNode !== el.ownerDocument?.body)) {
|
|
|
if (!delta.ops.length || delta.ops[delta.ops.length - 1].insert !== '\n') {
|
|
|
- delta.insert('\n');
|
|
|
+ insertBlockBreak(delta, el, listType);
|
|
|
}
|
|
|
}
|
|
|
}
|