diff --git a/packages/vrender-core/__tests__/richtext_editor/richtext_editor.test.ts b/packages/vrender-core/__tests__/richtext_editor/richtext_editor.test.ts new file mode 100644 index 000000000..6be13593b --- /dev/null +++ b/packages/vrender-core/__tests__/richtext_editor/richtext_editor.test.ts @@ -0,0 +1,279 @@ +/* eslint-disable @typescript-eslint/ban-ts-comment */ +// @ts-nocheck +import { findCursorIdxByConfigIndex, findConfigIndexByCursorIdx } from '../../src/plugins/builtin-plugin/edit-module'; + +const textConfig1 = [ + { + text: '我', + fontSize: 16, + lineHeight: 26, + textAlign: 'center', + background: 'orange', + fill: '#0f51b5' + }, + { + text: '们', + fontSize: 16, + lineHeight: 26, + textAlign: 'center', + background: 'orange', + fill: '#0f51b5' + }, + { + text: '是', + fontSize: 16, + lineHeight: 26, + textAlign: 'center', + background: 'orange', + fill: '#0f51b5' + } +]; +const textConfig2 = [ + { + text: '我', + fontSize: 16, + lineHeight: 26, + textAlign: 'center', + background: 'orange', + fill: '#0f51b5' + }, + { + fill: '#0f51b5', + text: '\n', + fontSize: 16, + lineHeight: 26, + textAlign: 'center', + background: 'orange', + isComposing: false + }, + { + fill: '#0f51b5', + text: '\n', + fontSize: 16, + lineHeight: 26, + textAlign: 'center', + background: 'orange', + isComposing: false + }, + { + fill: '#0f51b5', + text: '\n', + fontSize: 16, + lineHeight: 26, + textAlign: 'center', + background: 'orange', + isComposing: false + }, + { + fill: '#0f51b5', + text: '\n', + fontSize: 16, + lineHeight: 26, + textAlign: 'center', + background: 'orange', + isComposing: false + }, + { + text: '们', + fontSize: 16, + lineHeight: 26, + textAlign: 'center', + background: 'orange', + fill: '#0f51b5' + }, + { + text: '是', + fontSize: 16, + lineHeight: 26, + textAlign: 'center', + background: 'orange', + fill: '#0f51b5' + } +]; +const textConfig3 = [ + { + text: '我', + fontSize: 16, + lineHeight: 26, + textAlign: 'center', + background: 'orange', + fill: '#0f51b5' + }, + { + fill: '#0f51b5', + text: '\n', + fontSize: 16, + lineHeight: 26, + textAlign: 'center', + background: 'orange', + isComposing: false + }, + { + fill: '#0f51b5', + text: '\n', + fontSize: 16, + lineHeight: 26, + textAlign: 'center', + background: 'orange', + isComposing: false + }, + { + fill: '#0f51b5', + text: 'a', + fontSize: 16, + lineHeight: 26, + textAlign: 'center', + background: 'orange', + isComposing: false + }, + { + fill: '#0f51b5', + text: '\n', + fontSize: 16, + lineHeight: 26, + textAlign: 'center', + background: 'orange', + isComposing: false + }, + { + fill: '#0f51b5', + text: '\n', + fontSize: 16, + lineHeight: 26, + textAlign: 'center', + background: 'orange', + isComposing: false + }, + { + text: '们', + fontSize: 16, + lineHeight: 26, + textAlign: 'center', + background: 'orange', + fill: '#0f51b5' + }, + { + text: '是', + fontSize: 16, + lineHeight: 26, + textAlign: 'center', + background: 'orange', + fill: '#0f51b5' + } +]; + +describe('richtext_editor', () => { + it('richtext_editor findConfigIndexByCursorIdx config 1', () => { + // expect(findConfigIndexByCursorIdx(textConfig1, -0.1)).toEqual(0); + // expect(findConfigIndexByCursorIdx(textConfig1, 0.1)).toEqual(0); + // expect(findConfigIndexByCursorIdx(textConfig1, 0.9)).toEqual(0); + // expect(findConfigIndexByCursorIdx(textConfig1, 1.1)).toEqual(1); + // expect(findConfigIndexByCursorIdx(textConfig1, 1.9)).toEqual(1); + // expect(findConfigIndexByCursorIdx(textConfig1, 2.1)).toEqual(2); + // expect(findConfigIndexByCursorIdx(textConfig1, -0.1, 1)).toEqual(0); + // expect(findConfigIndexByCursorIdx(textConfig1, 0.1, 1)).toEqual(1); + // expect(findConfigIndexByCursorIdx(textConfig1, 0.9, 1)).toEqual(1); + // expect(findConfigIndexByCursorIdx(textConfig1, 1.1, 1)).toEqual(2); + // expect(findConfigIndexByCursorIdx(textConfig1, 1.9, 1)).toEqual(2); + // expect(findConfigIndexByCursorIdx(textConfig1, 2.1, 1)).toEqual(2); + }); + + it('richtext_editor findConfigIndexByCursorIdx config 2', () => { + // expect(findConfigIndexByCursorIdx(textConfig2, -0.1)).toEqual(0); + // expect(findConfigIndexByCursorIdx(textConfig2, 0.1)).toEqual(0); + // expect(findConfigIndexByCursorIdx(textConfig2, 0.9)).toEqual(0); + // expect(findConfigIndexByCursorIdx(textConfig2, 1.1)).toEqual(2); + // expect(findConfigIndexByCursorIdx(textConfig2, 1.9)).toEqual(2); + // expect(findConfigIndexByCursorIdx(textConfig2, 2.1)).toEqual(3); + // expect(findConfigIndexByCursorIdx(textConfig2, 2.9)).toEqual(3); + // expect(findConfigIndexByCursorIdx(textConfig2, 3.1)).toEqual(4); + // expect(findConfigIndexByCursorIdx(textConfig2, 3.9)).toEqual(4); + // expect(findConfigIndexByCursorIdx(textConfig2, 4.1)).toEqual(5); + // expect(findConfigIndexByCursorIdx(textConfig2, 4.9)).toEqual(5); + // expect(findConfigIndexByCursorIdx(textConfig2, 5.1)).toEqual(6); + // expect(findConfigIndexByCursorIdx(textConfig2, -0.1, 1)).toEqual(0); + // expect(findConfigIndexByCursorIdx(textConfig2, 0.1, 1)).toEqual(2); + // expect(findConfigIndexByCursorIdx(textConfig2, 0.9, 1)).toEqual(2); + // expect(findConfigIndexByCursorIdx(textConfig2, 1.1, 1)).toEqual(3); + // expect(findConfigIndexByCursorIdx(textConfig2, 1.9, 1)).toEqual(3); + // expect(findConfigIndexByCursorIdx(textConfig2, 2.1, 1)).toEqual(4); + // expect(findConfigIndexByCursorIdx(textConfig2, 2.9, 1)).toEqual(4); + // expect(findConfigIndexByCursorIdx(textConfig2, 3.1, 1)).toEqual(5); + // expect(findConfigIndexByCursorIdx(textConfig2, 3.9, 1)).toEqual(5); + // expect(findConfigIndexByCursorIdx(textConfig2, 4.1, 1)).toEqual(6); + // expect(findConfigIndexByCursorIdx(textConfig2, 4.9, 1)).toEqual(6); + // expect(findConfigIndexByCursorIdx(textConfig2, 5.1, 1)).toEqual(6); + }); + + it('richtext_editor findConfigIndexByCursorIdx config 3', () => { + // expect(findConfigIndexByCursorIdx(textConfig3, -0.1)).toEqual(0); + // expect(findConfigIndexByCursorIdx(textConfig3, 0.1)).toEqual(0); + // expect(findConfigIndexByCursorIdx(textConfig3, 0.9)).toEqual(0); + // expect(findConfigIndexByCursorIdx(textConfig3, 1.1)).toEqual(2); + // expect(findConfigIndexByCursorIdx(textConfig3, 1.9)).toEqual(2); + // expect(findConfigIndexByCursorIdx(textConfig3, 2.1)).toEqual(3); + // expect(findConfigIndexByCursorIdx(textConfig3, 2.9)).toEqual(3); + // expect(findConfigIndexByCursorIdx(textConfig3, 3.1)).toEqual(5); + // expect(findConfigIndexByCursorIdx(textConfig3, 3.9)).toEqual(5); + // expect(findConfigIndexByCursorIdx(textConfig3, 4.1)).toEqual(6); + // expect(findConfigIndexByCursorIdx(textConfig3, 4.9)).toEqual(6); + // expect(findConfigIndexByCursorIdx(textConfig3, 5.1)).toEqual(7); + // expect(findConfigIndexByCursorIdx(textConfig3, 5.9)).toEqual(7); + // expect(findConfigIndexByCursorIdx(textConfig3, -0.1, 1)).toEqual(0); + // expect(findConfigIndexByCursorIdx(textConfig3, 0.1, 1)).toEqual(2); + // expect(findConfigIndexByCursorIdx(textConfig3, 0.9, 1)).toEqual(2); + // expect(findConfigIndexByCursorIdx(textConfig3, 1.1, 1)).toEqual(3); + // expect(findConfigIndexByCursorIdx(textConfig3, 1.9, 1)).toEqual(3); + // expect(findConfigIndexByCursorIdx(textConfig3, 2.1, 1)).toEqual(5); + // expect(findConfigIndexByCursorIdx(textConfig3, 2.9, 1)).toEqual(5); + // expect(findConfigIndexByCursorIdx(textConfig3, 3.1, 1)).toEqual(6); + // expect(findConfigIndexByCursorIdx(textConfig3, 3.9, 1)).toEqual(6); + // expect(findConfigIndexByCursorIdx(textConfig3, 4.1, 1)).toEqual(7); + // expect(findConfigIndexByCursorIdx(textConfig3, 4.9, 1)).toEqual(7); + // expect(findConfigIndexByCursorIdx(textConfig3, 5.1, 1)).toEqual(7); + // expect(findConfigIndexByCursorIdx(textConfig3, 5.9, 1)).toEqual(7); + }); + + it('richtext_editor findCursorIdxByConfigIndex config 1', () => { + // expect(findCursorIdxByConfigIndex(textConfig1, 0)).toEqual(0.1); + // expect(findCursorIdxByConfigIndex(textConfig1, 1)).toEqual(1.1); + // expect(findCursorIdxByConfigIndex(textConfig1, 2)).toEqual(2.1); + // expect(findCursorIdxByConfigIndex(textConfig1, 0, -0.1)).toEqual(-0.1); + // expect(findCursorIdxByConfigIndex(textConfig1, 1, -0.1)).toEqual(0.9); + // expect(findCursorIdxByConfigIndex(textConfig1, 2, -0.1)).toEqual(1.9); + }); + it('richtext_editor findCursorIdxByConfigIndex config 2', () => { + // expect(findCursorIdxByConfigIndex(textConfig2, 0)).toEqual(0.1); + // expect(findCursorIdxByConfigIndex(textConfig2, 1)).toEqual(0.9); + // expect(findCursorIdxByConfigIndex(textConfig2, 2)).toEqual(0.9); + // expect(findCursorIdxByConfigIndex(textConfig2, 3)).toEqual(1.9); + // expect(findCursorIdxByConfigIndex(textConfig2, 4)).toEqual(2.9); + // expect(findCursorIdxByConfigIndex(textConfig2, 5)).toEqual(4.1); + // expect(findCursorIdxByConfigIndex(textConfig2, 6)).toEqual(5.1); + // expect(findCursorIdxByConfigIndex(textConfig2, 0, -0.1)).toEqual(-0.1); + // expect(findCursorIdxByConfigIndex(textConfig2, 1, -0.1)).toEqual(0.9); + // expect(findCursorIdxByConfigIndex(textConfig2, 2, -0.1)).toEqual(0.9); + // expect(findCursorIdxByConfigIndex(textConfig2, 3, -0.1)).toEqual(1.9); + // expect(findCursorIdxByConfigIndex(textConfig2, 4, -0.1)).toEqual(2.9); + // expect(findCursorIdxByConfigIndex(textConfig2, 5, -0.1)).toEqual(3.9); + // expect(findCursorIdxByConfigIndex(textConfig2, 6, -0.1)).toEqual(4.9); + }); + it('richtext_editor findCursorIdxByConfigIndex config 3', () => { + // expect(findCursorIdxByConfigIndex(textConfig3, 0)).toEqual(0.1); + // expect(findCursorIdxByConfigIndex(textConfig3, 1)).toEqual(0.9); + // expect(findCursorIdxByConfigIndex(textConfig3, 2)).toEqual(0.9); + // expect(findCursorIdxByConfigIndex(textConfig3, 3)).toEqual(2.1); + // expect(findCursorIdxByConfigIndex(textConfig3, 4)).toEqual(2.9); + // expect(findCursorIdxByConfigIndex(textConfig3, 5)).toEqual(2.9); + // expect(findCursorIdxByConfigIndex(textConfig3, 6)).toEqual(4.1); + // expect(findCursorIdxByConfigIndex(textConfig3, 7)).toEqual(5.1); + // expect(findCursorIdxByConfigIndex(textConfig3, 0, -0.1)).toEqual(-0.1); + // expect(findCursorIdxByConfigIndex(textConfig3, 1, -0.1)).toEqual(0.9); + // expect(findCursorIdxByConfigIndex(textConfig3, 2, -0.1)).toEqual(0.9); + // expect(findCursorIdxByConfigIndex(textConfig3, 3, -0.1)).toEqual(1.9); + // expect(findCursorIdxByConfigIndex(textConfig3, 4, -0.1)).toEqual(2.9); + // expect(findCursorIdxByConfigIndex(textConfig3, 5, -0.1)).toEqual(2.9); + // expect(findCursorIdxByConfigIndex(textConfig3, 6, -0.1)).toEqual(3.9); + // expect(findCursorIdxByConfigIndex(textConfig3, 7, -0.1)).toEqual(4.9); + }); +}); diff --git a/packages/vrender-core/src/core/contributions/env/base-contribution.ts b/packages/vrender-core/src/core/contributions/env/base-contribution.ts index 0baed3f40..805ba7ca1 100644 --- a/packages/vrender-core/src/core/contributions/env/base-contribution.ts +++ b/packages/vrender-core/src/core/contributions/env/base-contribution.ts @@ -190,4 +190,11 @@ export abstract class BaseEnvContribution implements IEnvContribution { ): Promise<{ loadState: 'success' | 'fail' }> { return { loadState: 'fail' }; } + + isMacOS() { + return false; + } + copyToClipBoard(text: string) { + return Promise.resolve(null); + } } diff --git a/packages/vrender-core/src/core/global.ts b/packages/vrender-core/src/core/global.ts index 4c1ec7f75..198890436 100644 --- a/packages/vrender-core/src/core/global.ts +++ b/packages/vrender-core/src/core/global.ts @@ -423,4 +423,18 @@ export class DefaultGlobal implements IGlobal { } return this.envContribution.getElementTopLeft(dom, baseWindow); } + + isMacOS(): boolean { + if (!this._env) { + this.setEnv('browser'); + } + return this.envContribution.isMacOS(); + } + + copyToClipBoard(text: string) { + if (!this._env) { + this.setEnv('browser'); + } + return this.envContribution.copyToClipBoard(text); + } } diff --git a/packages/vrender-core/src/graphic/richtext.ts b/packages/vrender-core/src/graphic/richtext.ts index e7fe6d0a5..8685fd089 100644 --- a/packages/vrender-core/src/graphic/richtext.ts +++ b/packages/vrender-core/src/graphic/richtext.ts @@ -1,5 +1,5 @@ import type { IAABBBounds } from '@visactor/vutils'; -import { isNumber } from '@visactor/vutils'; +import { isNumber, isString } from '@visactor/vutils'; import type { IRichText, IRichTextCharacter, @@ -13,7 +13,9 @@ import type { IStage, ILayer, IRichTextIcon, - EventPoint + EventPoint, + IRichTextFrame, + ISetAttributeContext } from '../interface'; import { Graphic, GRAPHIC_UPDATE_TAG_KEY, NOWORK_ANIMATE_ATTR } from './graphic'; import { DefaultRichTextAttribute } from './config'; @@ -187,6 +189,45 @@ export class RichText extends Graphic implements IRic return getTheme(this).richtext; } + static AllSingleCharacter(cache: IRichTextFrame | IRichTextGraphicAttribute['textConfig']) { + if ((cache as IRichTextFrame).lines) { + const frame = cache as IRichTextFrame; + return frame.lines.every(line => + line.paragraphs.every(item => !(item.text && isString(item.text) && RichText.splitText(item.text).length > 1)) + ); + } + // isComposing的不算 + const tc = cache as IRichTextGraphicAttribute['textConfig']; + return tc.every( + item => + (item as any).isComposing || + !((item as any).text && isString((item as any).text) && RichText.splitText((item as any).text).length > 1) + ); + } + + static splitText(text: string) { + // 😁这种emoji长度算两个,所以得处理一下 + return Array.from(text); + } + + static TransformTextConfig2SingleCharacter(textConfig: IRichTextGraphicAttribute['textConfig']) { + const tc: IRichTextGraphicAttribute['textConfig'] = []; + textConfig.forEach((item: IRichTextParagraphCharacter) => { + const textList = RichText.splitText(item.text.toString()); + if (isString(item.text) && textList.length > 1) { + // 拆分 + for (let i = 0; i < textList.length; i++) { + const t = textList[i]; + tc.push({ ...item, text: t }); + } + } else { + tc.push(item); + } + }); + + return tc; + } + protected updateAABBBounds( attribute: IRichTextGraphicAttribute, richtextTheme: Required, @@ -263,12 +304,12 @@ export class RichText extends Graphic implements IRic protected needUpdateTag(key: string): boolean { return super.needUpdateTag(key, RICHTEXT_UPDATE_TAG_KEY); } - getFrameCache(): Frame { + getFrameCache(): IRichTextFrame { if (this.shouldUpdateShape()) { this.doUpdateFrameCache(); this.clearUpdateShapeTag(); } - return this._frameCache; + return this._frameCache as IRichTextFrame; } get cliped() { @@ -321,7 +362,6 @@ export class RichText extends Graphic implements IRic doUpdateFrameCache(tc?: IRichTextCharacter[]) { // 1. 测量,生成paragraph const { - textConfig: _tc = [], maxWidth, maxHeight, width, @@ -333,8 +373,18 @@ export class RichText extends Graphic implements IRic textBaseline, layoutDirection, singleLine, - disableAutoWrapLine + disableAutoWrapLine, + editable } = this.attribute; + + let { textConfig: _tc = [] } = this.attribute; + + // 预处理editable,将textConfig中的text转换为单个字符 + if (editable && _tc.length > 0 && !RichText.AllSingleCharacter(_tc)) { + _tc = RichText.TransformTextConfig2SingleCharacter(_tc); + this.attribute.textConfig = _tc; + } + const paragraphs: (Paragraph | RichTextIcon)[] = []; const textConfig = tc ?? _tc; @@ -428,6 +478,9 @@ export class RichText extends Graphic implements IRic this._frameCache?.icons ); const wrapper = new Wrapper(frame); + // @since 0.22.0 + // 如果可编辑的话,则支持多换行符 + wrapper.newLine = editable; if (disableAutoWrapLine) { let lineCount = 0; let skip = false; @@ -486,6 +539,18 @@ export class RichText extends Graphic implements IRic }); } + // 处理空行 + if (editable) { + frame.lines.forEach(item => { + const lastParagraphs = item.paragraphs; + item.paragraphs = item.paragraphs.filter(p => (p as any).text !== ''); + if (item.paragraphs.length === 0 && lastParagraphs.length) { + (lastParagraphs[0] as any).text = '\n'; + item.paragraphs.push(lastParagraphs[0]); + } + }); + } + this._frameCache = frame; // this.bindIconEvent(); diff --git a/packages/vrender-core/src/graphic/richtext/line.ts b/packages/vrender-core/src/graphic/richtext/line.ts index 60a9b8b4f..ee1c1cd0f 100644 --- a/packages/vrender-core/src/graphic/richtext/line.ts +++ b/packages/vrender-core/src/graphic/richtext/line.ts @@ -223,7 +223,7 @@ export default class Line { applyStrokeStyle(ctx, paragraph.character); // 下面绘制underline和line-through时需要设置FillStyle applyFillStyle(ctx, paragraph.character, b); - paragraph.draw(ctx, y + this.ascent, x, index === 0, this.textAlign); + paragraph.draw(ctx, y, this.ascent, x, index === 0, this.textAlign); }); } diff --git a/packages/vrender-core/src/graphic/richtext/paragraph.ts b/packages/vrender-core/src/graphic/richtext/paragraph.ts index 0751bff05..f73eb5f0f 100644 --- a/packages/vrender-core/src/graphic/richtext/paragraph.ts +++ b/packages/vrender-core/src/graphic/richtext/paragraph.ts @@ -142,7 +142,8 @@ export default class Paragraph { } } - draw(ctx: IContext2d, baseline: number, deltaLeft: number, isLineFirst: boolean, textAlign: string) { + draw(ctx: IContext2d, top: number, ascent: number, deltaLeft: number, isLineFirst: boolean, textAlign: string) { + let baseline = top + ascent; let text = this.text; let left = this.left + deltaLeft; baseline += this.top; @@ -209,6 +210,20 @@ export default class Paragraph { baseline = 0; } + if (this.character.fill) { + if (this.character.background && (!this.character.backgroundOpacity || this.character.backgroundOpacity > 0)) { + const fillStyle = ctx.fillStyle; + const globalAlpha = ctx.globalAlpha; + ctx.fillStyle = this.character.background; + if (this.character.backgroundOpacity !== void 0) { + ctx.globalAlpha = this.character.backgroundOpacity; + } + ctx.fillRect(left, top, this.widthOrigin || this.width, this.lineHeight); + ctx.fillStyle = fillStyle; + ctx.globalAlpha = globalAlpha; + } + } + const { lineWidth = 1 } = this.character; if (this.character.stroke && lineWidth) { ctx.strokeText(text, left, baseline); diff --git a/packages/vrender-core/src/graphic/richtext/utils.ts b/packages/vrender-core/src/graphic/richtext/utils.ts index 44bf509bf..03e3def31 100644 --- a/packages/vrender-core/src/graphic/richtext/utils.ts +++ b/packages/vrender-core/src/graphic/richtext/utils.ts @@ -206,6 +206,42 @@ export function getStrByWithCanvas( return index; } +export function getWordStartEndIdx(string: string, index: number) { + let startIdx = index; + // 切分前后都是英文字母数字下划线,向前找到非英文字母处换行 + while ( + (regLetter.test(string[startIdx - 1]) && regLetter.test(string[startIdx])) || + // 行首标点符号处理 + regPunctuation.test(string[startIdx]) + ) { + startIdx--; + // 无法满足所有条件,放弃匹配,直接截断,避免陷入死循环 + if (startIdx <= 0) { + break; + } + } + + let endIdx = index; + // 切分前后都是英文字母数字下划线,向前找到非英文字母处换行 + while ( + (regLetter.test(string[endIdx + 1]) && regLetter.test(string[endIdx])) || + // 行首标点符号处理 + regPunctuation.test(string[endIdx]) + ) { + endIdx++; + // 无法满足所有条件,放弃匹配,直接截断,避免陷入死循环 + if (endIdx >= string.length) { + break; + } + } + endIdx = Math.min(endIdx + 1, string.length); + + return { + startIdx, + endIdx + }; +} + /** * 向前找到单词结尾处换行 * @param string @@ -240,7 +276,7 @@ export function testLetter2(string: string, index: number) { let i = index; // 切分前后都是英文字母数字下划线,向前找到非英文字母处换行 while ( - (regLetter.test(string[i - 1]) && regLetter.test(string[i])) || + (regLetter.test(string[i + 1]) && regLetter.test(string[i])) || // 行首标点符号处理 regPunctuation.test(string[i]) ) { @@ -250,7 +286,7 @@ export function testLetter2(string: string, index: number) { return i; } } - return i; + return i + 1; } // 测量文字详细信息 diff --git a/packages/vrender-core/src/graphic/richtext/wrapper.ts b/packages/vrender-core/src/graphic/richtext/wrapper.ts index 54246084e..64f6a3320 100644 --- a/packages/vrender-core/src/graphic/richtext/wrapper.ts +++ b/packages/vrender-core/src/graphic/richtext/wrapper.ts @@ -47,6 +47,7 @@ export default class Wrapper { lineBuffer: (Paragraph | RichTextIcon)[]; direction: 'horizontal' | 'vertical'; directionKey: { width: string; height: string }; + newLine: boolean; // 空换行符是否新增一行 constructor(frame: Frame) { this.frame = frame; @@ -164,7 +165,7 @@ export default class Wrapper { this.send(); } - if (paragraph.text.length === 0) { + if (paragraph.text.length === 0 && !this.newLine) { return; } // 换行符分割出的Paragraph不进入line diff --git a/packages/vrender-core/src/index.ts b/packages/vrender-core/src/index.ts index c77373cbf..390993d41 100644 --- a/packages/vrender-core/src/index.ts +++ b/packages/vrender-core/src/index.ts @@ -101,3 +101,4 @@ export * from './plugins/builtin-plugin/3dview-transform-plugin'; export * from './plugins/builtin-plugin/flex-layout-plugin'; export * from './animate/easing-func'; +export * from './plugins/builtin-plugin/edit-module'; diff --git a/packages/vrender-core/src/interface/global.ts b/packages/vrender-core/src/interface/global.ts index efca682f0..62dce9188 100644 --- a/packages/vrender-core/src/interface/global.ts +++ b/packages/vrender-core/src/interface/global.ts @@ -134,6 +134,9 @@ export interface IEnvContribution ) => Promise<{ loadState: 'success' | 'fail'; }>; + + isMacOS: () => boolean; + copyToClipBoard: (text: string) => Promise; } export type IMiniAppEnvParams = { @@ -213,6 +216,8 @@ export interface IGlobal extends Omit boolean; isSafari: () => boolean; + isMacOS: () => boolean; + copyToClipBoard: (text: string) => Promise; /** * 获取环境中最大静态canvas的数量,纯粹canvas diff --git a/packages/vrender-core/src/interface/graphic/richText.ts b/packages/vrender-core/src/interface/graphic/richText.ts index 01d788a52..2b30f468a 100644 --- a/packages/vrender-core/src/interface/graphic/richText.ts +++ b/packages/vrender-core/src/interface/graphic/richText.ts @@ -60,6 +60,10 @@ export type IRichTextParagraphCharacter = IRichTextBasicCharacter & { opacity?: number; fillOpacity?: number; strokeOpacity?: number; + // 仅支持纯色背景 + background?: string; + // 背景透明度 + backgroundOpacity?: number; // direction?: RichTextLayoutDirectionType; }; diff --git a/packages/vrender-core/src/plugins/builtin-plugin/edit-module.ts b/packages/vrender-core/src/plugins/builtin-plugin/edit-module.ts index 287f34d4f..c57f9761e 100644 --- a/packages/vrender-core/src/plugins/builtin-plugin/edit-module.ts +++ b/packages/vrender-core/src/plugins/builtin-plugin/edit-module.ts @@ -1,18 +1,108 @@ +import { application } from '../../application'; import type { IRichText, IRichTextCharacter, IRichTextParagraphCharacter } from '../../interface'; -import { IRichTextIcon, IRichTextParagraph } from '../../interface'; -export function findCursorIndexIgnoreLinebreak(textConfig: IRichTextCharacter[], cursorIndex: number): number { - let index = 0; - for (index = 0; index < textConfig.length; index++) { - const c = textConfig[index] as IRichTextParagraphCharacter; - if (!(c.text && c.text === '\n')) { - cursorIndex--; +// function getMaxConfigIndexIgnoreLinebreak(textConfig: IRichTextCharacter[]) { +// let idx = 0; +// for (let i = 0; i < textConfig.length; i++) { +// const c = textConfig[i] as IRichTextParagraphCharacter; +// if (c.text !== '\n') { +// idx++; +// } +// } +// return Math.max(idx - 1, 0); +// } + +/** + * 找到cursorIndex所在的textConfig的位置,给出的index就是要插入的准确位置 + * @param textConfig + * @param cursorIndex + * @returns + */ +export function findConfigIndexByCursorIdx(textConfig: IRichTextCharacter[], cursorIndex: number): number { + if (cursorIndex < 0) { + return 0; + } + + // 排序找到对应的元素 + const intCursorIndex = Math.round(cursorIndex); + let tempCursorIndex = intCursorIndex; + // 跳过连续换行符中的第一个换行符 + let lineBreak = false; + let configIdx = 0; + for (configIdx = 0; configIdx < textConfig.length && tempCursorIndex >= 0; configIdx++) { + const c = textConfig[configIdx] as IRichTextParagraphCharacter; + if (c.text === '\n') { + tempCursorIndex -= Number(lineBreak); + lineBreak = true; + } else { + tempCursorIndex--; + lineBreak = false; + } + } + // 说明过限了 + if (tempCursorIndex >= 0) { + return textConfig.length; + } + configIdx -= 1; + + // 如果有换行,一定在换行符左边写 + if (cursorIndex > intCursorIndex && !lineBreak) { + configIdx += 1; + } + return configIdx; +} + +/** + * 根据configIndex找到cursorIndex的位置,忽略单个换行符,连续换行符的时候只忽略第一个 + * @param textConfig + * @param configIndex + * @returns + */ +export function findCursorIdxByConfigIndex(textConfig: IRichTextCharacter[], configIndex: number): number { + let cursorIndex = 0; + if (configIndex < 0) { + return -0.1; + } + // 仅有一个\n,那不算 + // 如果有连续的\n,那就少算一个 + let lastLineBreak = false; + + for (let i = 0; i <= configIndex && i < textConfig.length; i++) { + const c = textConfig[i] as IRichTextParagraphCharacter; + if (c.text === '\n') { + cursorIndex += Number(lastLineBreak); + lastLineBreak = true; + } else { + cursorIndex++; + lastLineBreak = false; } - if (cursorIndex < 0) { - break; + } + cursorIndex = Math.max(cursorIndex - 1, 0); + + // 超出区间了直接设置到尾部,configIndex超过区间,cursorIndex不会超过 + if (configIndex > textConfig.length - 1) { + // 如果最后一行是一个换行符,那么就得是xx.9否则就是xx.1 + if ((textConfig[textConfig.length - 1] as any)?.text === '\n') { + return cursorIndex + 0.9; } + return cursorIndex + 0.1; } - return index; + + // 如果是这个configIdx对应到的是单个换行的话,那么算到下一个字符上 + const lineBreak = (textConfig[configIndex] as any)?.text === '\n'; + if (configIndex >= textConfig.length - 1 && lineBreak) { + return cursorIndex + 1 - 0.1; + } + const singleLineBreak = lineBreak && (textConfig[configIndex - 1] as any)?.text !== '\n'; + + // 光标往左放 + cursorIndex -= 0.1; + + // 如果是单行,那么这一个换行符没有算字符,光标要往右放 + if (singleLineBreak) { + cursorIndex += 0.2; + } + return cursorIndex; } export class EditModule { @@ -20,16 +110,16 @@ export class EditModule { textAreaDom: HTMLTextAreaElement; currRt: IRichText; isComposing: boolean; + composingConfigIdx: number; cursorIndex: number; selectionStartCursorIdx: number; // 输入的回调(composing的时候每次也会触发) - onInputCbList: Array< - (text: string, isComposing: boolean, cursorIdx: number, rt: IRichText, pos: 'left' | 'right') => void - >; + onInputCbList: Array<(text: string, isComposing: boolean, cursorIdx: number, rt: IRichText) => void>; // change的回调(composing确认才会触发) - onChangeCbList: Array< - (text: string, isComposing: boolean, cursorIdx: number, rt: IRichText, pos: 'left' | 'right') => void - >; + onChangeCbList: Array<(text: string, isComposing: boolean, cursorIdx: number, rt: IRichText) => void>; + onFocusInList: Array<() => void>; + onFocusOutList: Array<() => void>; + focusOutTimer: number; constructor(container?: HTMLElement) { this.container = container ?? document.body; @@ -41,18 +131,29 @@ export class EditModule { this.container.append(textAreaDom); this.textAreaDom = textAreaDom; this.isComposing = false; + this.composingConfigIdx = -1; this.onInputCbList = []; this.onChangeCbList = []; + this.onFocusInList = []; + this.onFocusOutList = []; } - onInput(cb: (text: string, isComposing: boolean, cursorIdx: number, rt: IRichText, pos: 'left' | 'right') => void) { + onInput(cb: (text: string, isComposing: boolean, cursorIdx: number, rt: IRichText) => void) { this.onInputCbList.push(cb); } - onChange(cb: (text: string, isComposing: boolean, cursorIdx: number, rt: IRichText, pos: 'left' | 'right') => void) { + onChange(cb: (text: string, isComposing: boolean, cursorIdx: number, rt: IRichText) => void) { this.onChangeCbList.push(cb); } + onFocusIn(cb: () => void) { + this.onFocusInList.push(cb); + } + + onFocusOut(cb: () => void) { + this.onFocusOutList.push(cb); + } + applyStyle(textAreaDom: HTMLTextAreaElement) { textAreaDom.setAttribute( 'style', @@ -62,9 +163,25 @@ export class EditModule { textAreaDom.addEventListener('input', this.handleInput); textAreaDom.addEventListener('compositionstart', this.handleCompositionStart); textAreaDom.addEventListener('compositionend', this.handleCompositionEnd); - window.addEventListener('keydown', this.handleKeyDown); + // 监听焦点 + textAreaDom.addEventListener('focusin', this.handleFocusIn); + textAreaDom.addEventListener('focusout', this.handleFocusOut); + application.global.addEventListener('keydown', this.handleKeyDown); } + handleFocusIn = () => { + // this.focusOutTimer && clearTimeout(this.focusOutTimer); + // this.focusOutTimer = 0; + // this.onFocusInList && this.onFocusInList.forEach(cb => cb()); + }; + handleFocusOut = () => { + // 暂时注释,会导致非期待情况下的误关闭 + // // 延时触发,避免误关闭 + // this.focusOutTimer = setTimeout(() => { + // this.onFocusOutList && this.onFocusOutList.forEach(cb => cb()); + // }, 100); + }; + handleKeyDown = (e: KeyboardEvent) => { if (e.key === 'Delete' || e.key === 'Backspace') { this.handleInput({ data: null, type: 'Backspace' }); @@ -72,87 +189,177 @@ export class EditModule { }; handleCompositionStart = () => { - const { textConfig = [] } = this.currRt.attribute; - const cursorIndex = findCursorIndexIgnoreLinebreak(textConfig, this.cursorIndex); - const lastConfig = textConfig[cursorIndex]; - textConfig.splice(cursorIndex + 1, 0, { ...lastConfig, text: '' }); this.isComposing = true; + const { textConfig = [] } = this.currRt.attribute; + this.composingConfigIdx = this.cursorIndex < 0 ? 0 : findConfigIndexByCursorIdx(textConfig, this.cursorIndex); + if (this.cursorIndex < 0) { + const config = textConfig[0]; + textConfig.unshift({ fill: 'black', ...config, text: '' }); + } else { + const configIdx = this.composingConfigIdx; + const lastConfig = textConfig[configIdx] || textConfig[configIdx - 1]; + textConfig.splice(configIdx, 0, { ...lastConfig, text: '' }); + } }; handleCompositionEnd = () => { this.isComposing = false; + + const text = this.parseCompositionStr(this.composingConfigIdx); // 拆分上一次的内容 + // const { textConfig = [] } = this.currRt.attribute; + // const configIdx = this.composingConfigIdx; + + // const lastConfig = textConfig[configIdx]; + // textConfig.splice(configIdx, 1); + // const text = (lastConfig as any).text; + // const textList: string[] = text ? Array.from(text.toString()) : []; + // for (let i = 0; i < textList.length; i++) { + // textConfig.splice(i + configIdx, 0, { ...lastConfig, isComposing: false, text: textList[i] } as any); + // } + // this.currRt.setAttributes({ textConfig }); + // const nextConfigIdx = configIdx + textList.length; + // this.cursorIndex = findCursorIdxByConfigIndex(textConfig, nextConfigIdx); + this.composingConfigIdx = -1; + + this.onChangeCbList.forEach(cb => { + cb( + text, + this.isComposing, + // TODO 当换行后刚开始输入会有问题,后续看这里具体Cursor变换逻辑 + this.cursorIndex, + this.currRt + ); + }); + }; + + /** + * 复合输入以及粘贴,都会复制出一大段内容,这时候需要重新处理textConfig和cursorIndex + * 1. 拆分text到textConfig + * 2. 计算新的cursorIndex + * @param configIdx + */ + parseCompositionStr(configIdx: number) { const { textConfig = [] } = this.currRt.attribute; - const curIdx = findCursorIndexIgnoreLinebreak(textConfig, this.cursorIndex + 1); - const lastConfig = textConfig[curIdx]; - textConfig.splice(curIdx, 1); + const lastConfig = textConfig[configIdx]; + textConfig.splice(configIdx, 1); const text = (lastConfig as any).text; - const textList: string[] = Array.from(text.toString()); + const textList: string[] = text ? Array.from(text.toString()) : []; for (let i = 0; i < textList.length; i++) { - textConfig.splice(i + curIdx, 0, { ...lastConfig, text: textList[i] }); + textConfig.splice(i + configIdx, 0, { + fill: 'black', + ...lastConfig, + isComposing: false, + text: textList[i] + } as any); } this.currRt.setAttributes({ textConfig }); - this.onChangeCbList.forEach(cb => { - cb(text, this.isComposing, this.cursorIndex + textList.length, this.currRt, 'right'); - }); - }; + const nextConfigIdx = configIdx + textList.length; + this.cursorIndex = findCursorIdxByConfigIndex(textConfig, nextConfigIdx); + return text; + } handleInput = (ev: any) => { if (!this.currRt) { return; } + if (ev.inputType === 'historyUndo') { + return; + } + const { textConfig = [], ...rest } = this.currRt.attribute; + // 删完了,直接返回 + if (ev.type === 'Backspace' && !textConfig.length) { + return; + } + let str = (ev as any).data; - if (ev.type !== 'Backspace' && !str) { + if (!this.isComposing && ev.type !== 'Backspace' && !str) { str = '\n'; } - // 如果是回车,那就不往后+1 - const { textConfig = [] } = this.currRt.attribute; - // 如果有选中多个文字,那就先删除 - let startIdx = this.selectionStartCursorIdx; - let endIdx = this.cursorIndex; - if (startIdx > endIdx) { - [startIdx, endIdx] = [endIdx, startIdx]; + // 处理正反选 + if (this.selectionStartCursorIdx > this.cursorIndex) { + [this.cursorIndex, this.selectionStartCursorIdx] = [this.selectionStartCursorIdx, this.cursorIndex]; + } + + const startIdx = findConfigIndexByCursorIdx(textConfig, this.selectionStartCursorIdx); + const endIdx = findConfigIndexByCursorIdx(textConfig, this.cursorIndex); + + // composing的话会插入一个字符,所以往右加一个 + const lastConfigIdx = this.isComposing ? this.composingConfigIdx : Math.max(startIdx - 1, 0); + // 算一个默认属性 + let lastConfig: any = textConfig[lastConfigIdx]; + if (!lastConfig) { + lastConfig = { + fill: rest.fill ?? 'black', + stroke: rest.stroke ?? false, + fontSize: rest.fontSize ?? 12, + fontWeight: rest.fontWeight ?? 'normal' + }; + } + let nextConfig = lastConfig; + + if (startIdx !== endIdx) { + textConfig.splice(startIdx, endIdx - startIdx); + if (this.isComposing) { + this.composingConfigIdx = startIdx; + } } - // 无论是否composition都立刻恢复到没有选中的idx状态 - this.selectionStartCursorIdx = startIdx; - this.cursorIndex = startIdx; - // 转换成基于textConfig的 - startIdx = findCursorIndexIgnoreLinebreak(textConfig, startIdx); - const delta = this.selectionStartCursorIdx - startIdx; - endIdx = findCursorIndexIgnoreLinebreak(textConfig, endIdx); + let nextConfigIdx = startIdx; - const lastConfig = textConfig[startIdx + (this.isComposing ? 1 : 0)]; - let currConfig = lastConfig; + // 删除键 if (ev.type === 'Backspace' && !this.isComposing) { - if (startIdx !== endIdx) { - textConfig.splice(startIdx + 1, endIdx - startIdx); + if (startIdx === endIdx) { + if (startIdx <= 0) { + return; + } + // 删除 + textConfig.splice(startIdx - 1, 1); + nextConfigIdx = Math.max(startIdx - 1, 0); } else { - textConfig.splice(startIdx, 1); - startIdx -= 1; + // 不插入内容 } } else { - if (startIdx !== endIdx) { - textConfig.splice(startIdx + 1, endIdx - startIdx); + // 插入 + if (!this.isComposing) { + nextConfig = { fill: 'black', ...lastConfig, text: '' }; + textConfig.splice(startIdx, 0, nextConfig); + nextConfigIdx++; } + // 插入 + nextConfig.text = str; + // 标记isComposing,用来判定是否应该拆分成单个字符 + nextConfig.isComposing = this.isComposing; + } + + this.currRt.setAttributes({ textConfig }); + // 重新计算cursorIdx + // nextConfigIdx = Math.min(nextConfigIdx, textConfig.length - 1); + let cursorIndex = this.cursorIndex; + if (str && str.length > 1 && !this.isComposing) { + // 如果字符长度大于1且不是composing,那说明是粘贴 + // 拆分 + this.parseCompositionStr(nextConfigIdx - 1); + cursorIndex = this.cursorIndex; + } else { + // composing的时候不偏移,只有完整输入后才偏移 + cursorIndex = findCursorIdxByConfigIndex(textConfig, nextConfigIdx); if (!this.isComposing) { - currConfig = { ...lastConfig, text: '' }; - startIdx += 1; - textConfig.splice(startIdx, 0, currConfig); + this.cursorIndex = cursorIndex; + } else { + this.cursorIndex = this.selectionStartCursorIdx; } - (currConfig as any).text = str; } - this.currRt.setAttributes({ textConfig }); if (!this.isComposing) { this.onChangeCbList.forEach(cb => { - cb(str, this.isComposing, startIdx + delta, this.currRt, str === '\n' ? 'left' : 'right'); + cb(str, this.isComposing, cursorIndex, this.currRt); }); } else { this.onInputCbList.forEach(cb => { - cb(str, this.isComposing, startIdx + delta, this.currRt, str === '\n' ? 'left' : 'right'); + cb(str, this.isComposing, cursorIndex, this.currRt); }); } }; @@ -174,6 +381,8 @@ export class EditModule { this.textAreaDom.removeEventListener('input', this.handleInput); this.textAreaDom.removeEventListener('compositionstart', this.handleCompositionStart); this.textAreaDom.removeEventListener('compositionend', this.handleCompositionEnd); - window.removeEventListener('keydown', this.handleKeyDown); + this.textAreaDom.addEventListener('focusin', this.handleFocusOut); + this.textAreaDom.addEventListener('focusout', this.handleFocusOut); + application.global.removeEventListener('keydown', this.handleKeyDown); } } diff --git a/packages/vrender-core/src/plugins/builtin-plugin/richtext-edit-plugin-old.ts b/packages/vrender-core/src/plugins/builtin-plugin/richtext-edit-plugin-old.ts new file mode 100644 index 000000000..5594309da --- /dev/null +++ b/packages/vrender-core/src/plugins/builtin-plugin/richtext-edit-plugin-old.ts @@ -0,0 +1,671 @@ +// import type { IPointLike } from '@visactor/vutils'; +// import { isObject, isString, max, merge } from '@visactor/vutils'; +// import { Generator } from '../../common/generator'; +// import { createGroup, createLine, createRect } from '../../graphic'; +// import type { +// IGroup, +// ILine, +// IPlugin, +// IPluginService, +// IRect, +// IRichText, +// IRichTextCharacter, +// IRichTextFrame, +// IRichTextIcon, +// IRichTextLine, +// IRichTextParagraph, +// IRichTextParagraphCharacter, +// ITicker, +// ITimeline +// } from '../../interface'; +// import { EditModule, findCursorIndexIgnoreLinebreak } from './edit-module'; +// import { Animate, DefaultTicker, DefaultTimeline } from '../../animate'; + +// type UpdateType = 'input' | 'change' | 'onfocus' | 'defocus' | 'selection' | 'dispatch'; + +// class Selection { +// cacheSelectionStartCursorIdx: number; +// cacheCurCursorIdx: number; +// selectionStartCursorIdx: number; +// curCursorIdx: number; +// rt: IRichText; + +// constructor( +// cacheSelectionStartCursorIdx: number, +// cacheCurCursorIdx: number, +// selectionStartCursorIdx: number, +// curCursorIdx: number, +// rt: IRichText +// ) { +// this.curCursorIdx = curCursorIdx; +// this.selectionStartCursorIdx = selectionStartCursorIdx; +// this.cacheCurCursorIdx = cacheCurCursorIdx; +// this.cacheSelectionStartCursorIdx = cacheSelectionStartCursorIdx; +// this.rt = rt; +// } + +// isEmpty(): boolean { +// return this.selectionStartCursorIdx === this.curCursorIdx; +// } + +// hasFormat(key: string): boolean { +// return this.getFormat(key) != null; +// } + +// /** +// * 获取第idx中key的值 +// * @param key +// * @param idx cursor左侧字符的值,如果idx为-1则认为是特殊情况,为右侧字符的值 +// */ +// _getFormat(key: string, idx: number) { +// if (!this.rt) { +// return null; +// } +// const config = this.rt.attribute.textConfig as any; +// if (idx < 0) { +// idx = 0; +// } +// if (idx >= config.length) { +// return null; +// } +// return config[idx][key] ?? (this.rt.attribute as any)[key]; +// } +// getFormat(key: string): any { +// return this.getAllFormat(key)[0]; +// } + +// getAllFormat(key: string): any { +// const valSet = new Set(); +// let minCursorIdx = Math.min(this.selectionStartCursorIdx, this.curCursorIdx); +// let maxCursorIdx = Math.max(this.selectionStartCursorIdx, this.curCursorIdx); +// if (minCursorIdx === maxCursorIdx) { +// return [this._getFormat(key, minCursorIdx)]; +// } +// minCursorIdx++; +// maxCursorIdx++; +// const maxConfigIdx = this.rt.attribute.textConfig.length - 1; +// if (minCursorIdx > maxConfigIdx) { +// minCursorIdx = maxConfigIdx; +// } +// if (maxCursorIdx > maxConfigIdx) { +// maxCursorIdx = maxConfigIdx; +// } +// for (let i = minCursorIdx; i < maxCursorIdx; i++) { +// const val = this._getFormat(key, i); +// val && valSet.add(val); +// } +// return Array.from(valSet.values()); +// } +// } + +// export const FORMAT_TEXT_COMMAND = 'FORMAT_TEXT_COMMAND'; +// export const FORMAT_ELEMENT_COMMAND = 'FORMAT_ELEMENT_COMMAND'; +// export class RichTextEditPlugin implements IPlugin { +// name: 'RichTextEditPlugin' = 'RichTextEditPlugin'; +// activeEvent: 'onRegister' = 'onRegister'; +// pluginService: IPluginService; +// _uid: number = Generator.GenAutoIncrementId(); +// key: string = this.name + this._uid; +// editing: boolean = false; +// editLine: ILine; +// editBg: IGroup; +// pointerDown: boolean = false; +// // 用于selection中保存上一次click时候的位置 +// lastPoint?: IPointLike; +// editModule: EditModule; +// currRt: IRichText; + +// // 当前的cursor信息 +// // 0.1为第一个字符右侧, -0.1为第一个字符左侧 +// // 1.1为第二个字符右侧,0.9为第二个字符左侧 +// curCursorIdx: number; +// selectionStartCursorIdx: number; + +// commandCbs: Map void>>; +// updateCbs: Array<(type: UpdateType, p: RichTextEditPlugin) => void>; + +// ticker: ITicker; +// timeline: ITimeline; + +// // 富文本有align或者baseline的时候,需要对光标做偏移 +// protected declare deltaX: number; +// protected declare deltaY: number; + +// constructor() { +// this.commandCbs = new Map(); +// this.commandCbs.set(FORMAT_TEXT_COMMAND, [this.formatTextCommandCb]); +// this.updateCbs = []; +// this.timeline = new DefaultTimeline(); +// this.ticker = new DefaultTicker([this.timeline]); +// this.deltaX = 0; +// this.deltaY = 0; +// } + +// static CreateSelection(rt: IRichText) { +// if (!rt) { +// return null; +// } +// const { textConfig = [] } = rt.attribute; +// return new Selection( +// -1, +// textConfig.length - 1, +// findCursorIndexIgnoreLinebreak(textConfig, -1), +// findCursorIndexIgnoreLinebreak(textConfig, textConfig.length - 1), +// rt +// ); +// } + +// /** +// * 获取当前选择的区间范围 +// * @param defaultAll 如果force为true,又没有选择,则认为选择了所有然后进行匹配,如果为false,则认为什么都没有选择,返回null +// * @returns +// */ +// getSelection(defaultAll: boolean = false) { +// if (!this.currRt) { +// return null; +// } +// if ( +// this.selectionStartCursorIdx != null && +// this.curCursorIdx != null +// // this.selectionStartCursorIdx !== this.curCursorIdx && +// ) { +// return new Selection( +// this.selectionStartCursorIdx, +// this.curCursorIdx, +// findCursorIndexIgnoreLinebreak(this.currRt.attribute.textConfig, this.selectionStartCursorIdx), +// findCursorIndexIgnoreLinebreak(this.currRt.attribute.textConfig, this.curCursorIdx), +// this.currRt +// ); +// } else if (defaultAll) { +// return RichTextEditPlugin.CreateSelection(this.currRt); +// } +// return null; +// } + +// /* command */ +// formatTextCommandCb(payload: string, p: RichTextEditPlugin) { +// const rt = p.currRt; +// if (!rt) { +// return; +// } +// const selectionData = p.getSelection(); +// if (!selectionData) { +// return; +// } +// const { selectionStartCursorIdx, curCursorIdx } = selectionData; +// const minCursorIdx = Math.min(selectionStartCursorIdx, curCursorIdx); +// const maxCursorIdx = Math.max(selectionStartCursorIdx, curCursorIdx); +// const config = rt.attribute.textConfig.slice(minCursorIdx + 1, maxCursorIdx + 1); +// if (payload === 'bold') { +// config.forEach((item: IRichTextParagraphCharacter) => (item.fontWeight = 'bold')); +// } else if (payload === 'italic') { +// config.forEach((item: IRichTextParagraphCharacter) => (item.fontStyle = 'italic')); +// } else if (payload === 'underline') { +// config.forEach((item: IRichTextParagraphCharacter) => (item.underline = true)); +// } else if (payload === 'lineThrough') { +// config.forEach((item: IRichTextParagraphCharacter) => (item.lineThrough = true)); +// } else if (isObject(payload)) { +// config.forEach((item: IRichTextParagraphCharacter) => merge(item, payload)); +// } +// rt.setAttributes(rt.attribute); +// } + +// dispatchCommand(command: string, payload: any) { +// const cbs = this.commandCbs.get(command); +// cbs && cbs.forEach(cb => cb(payload, this)); +// this.updateCbs.forEach(cb => cb('dispatch', this)); +// } + +// registerCommand(command: string, cb: (payload: any, p: RichTextEditPlugin) => void) { +// const cbs: Array<(payload: any, p: RichTextEditPlugin) => void> = this.commandCbs.get(command) || []; +// cbs.push(cb); +// } + +// registerUpdateListener(cb: (type: UpdateType, p: RichTextEditPlugin) => void) { +// const cbs = this.updateCbs || []; +// cbs.push(cb); +// } + +// activate(context: IPluginService): void { +// this.pluginService = context; +// this.editModule = new EditModule(); +// // context.stage.on('click', this.handleClick); +// context.stage.on('pointermove', this.handleMove); +// context.stage.on('pointerdown', this.handlePointerDown); +// context.stage.on('pointerup', this.handlePointerUp); +// context.stage.on('pointerleave', this.handlePointerUp); + +// this.editModule.onInput(this.handleInput); +// this.editModule.onChange(this.handleChange); +// } + +// handleInput = (text: string, isComposing: boolean, cursorIdx: number, rt: IRichText, orient: 'left' | 'right') => { +// // 修改cursor的位置,但并不同步,因为这可能是临时的 +// const p = this.getPointByColumnIdx(cursorIdx, rt, orient); +// this.hideSelection(); +// this.setCursor(p.x, p.y1, p.y2); +// this.updateCbs.forEach(cb => cb('input', this)); +// }; +// handleChange = (text: string, isComposing: boolean, cursorIdx: number, rt: IRichText, orient: 'left' | 'right') => { +// // 修改cursor的位置,并同步到editModule +// const p = this.getPointByColumnIdx(cursorIdx, rt, orient); +// this.curCursorIdx = cursorIdx; +// this.selectionStartCursorIdx = cursorIdx; +// this.setCursorAndTextArea(p.x, p.y1, p.y2, rt); +// this.hideSelection(); +// this.updateCbs.forEach(cb => cb('change', this)); +// }; + +// handleMove = (e: PointerEvent) => { +// if (!this.isRichtext(e)) { +// return; +// } +// this.currRt = e.target as IRichText; +// this.handleEnter(e); +// (e.target as any).once('pointerleave', this.handleLeave); + +// this.showSelection(e); +// }; + +// showSelection(e: PointerEvent) { +// const cache = (e.target as IRichText).getFrameCache(); +// if (!(cache && this.editBg)) { +// return; +// } +// if (this.pointerDown) { +// let p0 = this.lastPoint; +// // 计算p1在字符中的位置 +// let p1 = this.getEventPosition(e); +// let line1Info = this.getLineByPoint(cache, p1); +// if (!line1Info) { +// return; +// } +// const column1 = this.getColumnByLinePoint(line1Info, p1); +// const y1 = line1Info.top; +// const y2 = line1Info.top + line1Info.height; +// let x = column1.left + column1.width; +// let cursorIndex = this.getColumnIndex(cache, column1); +// if (p1.x < column1.left + column1.width / 2) { +// x = column1.left; +// cursorIndex -= 1; +// } +// p1.x = x; +// p1.y = (y1 + y2) / 2; +// let line0Info = this.getLineByPoint(cache, p0); +// if (p0.y > p1.y || (p0.y === p1.y && p0.x > p1.x)) { +// [p0, p1] = [p1, p0]; +// [line1Info, line0Info] = [line0Info, line1Info]; +// } + +// this.editBg.removeAllChild(); +// if (line0Info === line1Info) { +// // const column0 = this.getColumnByLinePoint(line0Info, p0); +// this.editBg.setAttributes({ +// x: p0.x, +// y: line0Info.top, +// width: p1.x - p0.x, +// height: line0Info.height, +// fill: '#336df4', +// fillOpacity: 0.2 +// }); +// } else { +// this.editBg.setAttributes({ x: 0, y: line0Info.top, width: 0, height: 0 }); +// const startIdx = cache.lines.findIndex(item => item === line0Info); +// const endIdx = cache.lines.findIndex(item => item === line1Info); +// let y = 0; +// for (let i = startIdx; i <= endIdx; i++) { +// const line = cache.lines[i]; +// if (i === startIdx) { +// const p = line.paragraphs[line.paragraphs.length - 1]; +// this.editBg.add( +// createRect({ +// x: p0.x, +// y, +// width: p.left + p.width - p0.x, +// height: line.height, +// fill: '#336df4', +// fillOpacity: 0.2 +// }) +// ); +// } else if (i === endIdx) { +// const p = line.paragraphs[0]; +// this.editBg.add( +// createRect({ +// x: p.left, +// y, +// width: p1.x - p.left, +// height: line.height, +// fill: '#336df4', +// fillOpacity: 0.2 +// }) +// ); +// } else { +// const p0 = line.paragraphs[0]; +// const p1 = line.paragraphs[line.paragraphs.length - 1]; +// this.editBg.add( +// createRect({ +// x: p0.left, +// y, +// width: p1.left + p1.width - p0.left, +// height: line.height, +// fill: '#336df4', +// fillOpacity: 0.2 +// }) +// ); +// } +// y += line.height; +// } +// } + +// this.curCursorIdx = cursorIndex; +// this.setCursorAndTextArea(x, y1 + 2, y2 - 2, e.target as IRichText); + +// this.applyUpdate(); +// this.updateCbs.forEach(cb => cb('selection', this)); +// } +// } + +// hideSelection() { +// if (this.editBg) { +// this.editBg.removeAllChild(); +// this.editBg.setAttributes({ fill: 'transparent' }); +// } +// } + +// handlePointerDown = (e: PointerEvent) => { +// if (this.editing) { +// this.onFocus(e); +// } else { +// this.deFocus(e); +// } +// this.applyUpdate(); +// this.pointerDown = true; +// this.updateCbs.forEach(cb => cb(this.editing ? 'onfocus' : 'defocus', this)); +// console.log(this.selectionStartCursorIdx); +// }; +// handlePointerUp = (e: PointerEvent) => { +// this.pointerDown = false; +// }; + +// forceFocus(e: PointerEvent) { +// this.handleEnter(e); +// this.handlePointerDown(e); +// this.handlePointerUp(e); +// } + +// // 鼠标进入 +// handleEnter = (e: PointerEvent) => { +// this.editing = true; +// this.pluginService.stage.setCursor('text'); +// }; + +// // 鼠标离开 +// handleLeave = (e: PointerEvent) => { +// this.editing = false; +// this.pluginService.stage.setCursor('default'); +// }; + +// isRichtext(e: PointerEvent) { +// return !!(e.target && (e.target as any).type === 'richtext' && (e.target as any).attribute.editable); +// } + +// protected getEventPosition(e: PointerEvent): IPointLike { +// const p = this.pluginService.stage.eventPointTransform(e); + +// const p1 = { x: 0, y: 0 }; +// (e.target as IRichText).globalTransMatrix.transformPoint(p, p1); +// p1.x -= this.deltaX; +// p1.y -= this.deltaY; +// return p1; +// } + +// protected getLineByPoint(cache: IRichTextFrame, p1: IPointLike): IRichTextLine { +// let lineInfo = cache.lines[0]; +// for (let i = 0; i < cache.lines.length; i++) { +// if (lineInfo.top <= p1.y && lineInfo.top + lineInfo.height >= p1.y) { +// break; +// } +// lineInfo = cache.lines[i + 1]; +// } + +// return lineInfo; +// } +// protected getColumnByLinePoint(lineInfo: IRichTextLine, p1: IPointLike): IRichTextParagraph | IRichTextIcon { +// let columnInfo = lineInfo.paragraphs[0]; +// for (let i = 0; i < lineInfo.paragraphs.length; i++) { +// if (columnInfo.left <= p1.x && columnInfo.left + columnInfo.width >= p1.x) { +// break; +// } +// columnInfo = lineInfo.paragraphs[i]; +// } + +// return columnInfo; +// } + +// onFocus(e: PointerEvent) { +// this.deFocus(e); +// this.currRt = e.target as IRichText; + +// // 添加shadowGraphic +// const target = e.target as IRichText; +// RichTextEditPlugin.tryUpdateRichtext(target); +// const shadowRoot = target.attachShadow(); +// const cache = target.getFrameCache(); +// if (!cache) { +// return; +// } + +// this.deltaX = 0; +// this.deltaY = 0; +// const height = cache.actualHeight; +// const width = cache.lines.reduce((w, item) => Math.max(w, item.actualWidth), 0); +// if (cache.globalAlign === 'center') { +// this.deltaX = -width / 2; +// } else if (cache.globalAlign === 'right') { +// this.deltaX = -width; +// } +// if (cache.globalBaseline === 'middle') { +// this.deltaY = -height / 2; +// } else if (cache.globalBaseline === 'bottom') { +// this.deltaY = -height; +// } + +// shadowRoot.setAttributes({ shadowRootIdx: -1, x: this.deltaX, y: this.deltaY }); +// if (!this.editLine) { +// const line = createLine({ x: 0, y: 0, lineWidth: 1, stroke: 'black' }); +// // 不使用stage的Ticker,避免影响其他的动画以及受到其他动画影响 +// const animate = line.animate(); +// animate.setTimeline(this.timeline); +// animate.to({ opacity: 1 }, 10, 'linear').wait(700).to({ opacity: 0 }, 10, 'linear').wait(700).loop(Infinity); +// this.editLine = line; +// this.ticker.start(true); + +// const g = createGroup({ x: 0, y: 0, width: 0, height: 0 }); +// this.editBg = g; +// shadowRoot.add(this.editLine); +// shadowRoot.add(this.editBg); +// } + +// const p1 = this.getEventPosition(e); + +// const lineInfo = this.getLineByPoint(cache, p1); + +// if (lineInfo) { +// const columnInfo = this.getColumnByLinePoint(lineInfo, p1); +// if (!columnInfo) { +// return; +// } + +// let y1 = lineInfo.top; +// let y2 = lineInfo.top + lineInfo.height; +// let x = columnInfo.left + columnInfo.width; +// y1 += 2; +// y2 -= 2; +// let cursorIndex = this.getColumnIndex(cache, columnInfo); +// if (p1.x < columnInfo.left + columnInfo.width / 2) { +// x = columnInfo.left; +// cursorIndex -= 1; +// } + +// this.lastPoint = { x, y: (y1 + y2) / 2 }; + +// this.curCursorIdx = cursorIndex; +// this.selectionStartCursorIdx = cursorIndex; +// this.setCursorAndTextArea(x, y1, y2, target); +// } +// } + +// protected getPointByColumnIdx(idx: number, rt: IRichText, orient: 'left' | 'right') { +// const cache = rt.getFrameCache(); +// const column = this.getColumnByIndex(cache, idx); +// const height = rt.attribute.fontSize ?? (rt.attribute.textConfig?.[0] as any)?.fontSize; +// if (!column) { +// return { +// x: 0, +// y1: 0, +// y2: height +// }; +// } +// const { lineInfo, columnInfo } = column; +// let y1 = lineInfo.top; +// let y2 = lineInfo.top + lineInfo.height; +// const x = columnInfo.left + (orient === 'left' ? 0 : columnInfo.width); +// y1 += 2; +// y2 -= 2; + +// return { x, y1, y2 }; +// } + +// protected getColumnIndex(cache: IRichTextFrame, cInfo: IRichTextParagraph | IRichTextIcon) { +// // TODO 认为都是单个字符拆分的 +// let inputIndex = -1; +// for (let i = 0; i < cache.lines.length; i++) { +// const line = cache.lines[i]; +// for (let j = 0; j < line.paragraphs.length; j++) { +// inputIndex++; +// if (cInfo === line.paragraphs[j]) { +// return inputIndex; +// } +// } +// } +// return -1; +// } +// protected getColumnByIndex( +// cache: IRichTextFrame, +// index: number +// ): { +// lineInfo: IRichTextLine; +// columnInfo: IRichTextParagraph | IRichTextIcon; +// } | null { +// // TODO 认为都是单个字符拆分的 +// let inputIndex = -1; +// for (let i = 0; i < cache.lines.length; i++) { +// const lineInfo = cache.lines[i]; +// for (let j = 0; j < lineInfo.paragraphs.length; j++) { +// const columnInfo = lineInfo.paragraphs[j]; +// inputIndex++; +// if (inputIndex === index) { +// return { +// lineInfo, +// columnInfo +// }; +// } +// } +// } +// return null; +// } + +// protected setCursorAndTextArea(x: number, y1: number, y2: number, rt: IRichText) { +// this.editLine.setAttributes({ +// points: [ +// { x, y: y1 }, +// { x, y: y2 } +// ] +// }); +// const out = { x: 0, y: 0 }; +// rt.globalTransMatrix.getInverse().transformPoint({ x, y: y1 }, out); +// // TODO 考虑stage变换 +// const { left, top } = this.pluginService.stage.window.getBoundingClientRect(); +// out.x += left; +// out.y += top; + +// this.editModule.moveTo(out.x, out.y, rt, this.curCursorIdx, this.selectionStartCursorIdx); +// } +// protected setCursor(x: number, y1: number, y2: number) { +// this.editLine.setAttributes({ +// points: [ +// { x, y: y1 }, +// { x, y: y2 } +// ] +// }); +// } + +// applyUpdate() { +// this.pluginService.stage.renderNextFrame(); +// } +// deFocus(e: PointerEvent) { +// const target = this.currRt as IRichText; +// if (!target) { +// return; +// } +// target.detachShadow(); +// this.currRt = null; +// if (this.editLine) { +// this.editLine.parent.removeChild(this.editLine); +// this.editLine.release(); +// this.editLine = null; + +// this.editBg.parent.removeChild(this.editBg); +// this.editBg.release(); +// this.editBg = null; +// } +// } + +// static splitText(text: string) { +// // 😁这种emoji长度算两个,所以得处理一下 +// return Array.from(text); +// } + +// static tryUpdateRichtext(richtext: IRichText) { +// const cache = richtext.getFrameCache(); +// if ( +// !cache.lines.every(line => +// line.paragraphs.every( +// item => !(item.text && isString(item.text) && RichTextEditPlugin.splitText(item.text).length > 1) +// ) +// ) +// ) { +// const tc: IRichTextCharacter[] = []; +// richtext.attribute.textConfig.forEach((item: IRichTextParagraphCharacter) => { +// const textList = RichTextEditPlugin.splitText(item.text.toString()); +// if (isString(item.text) && textList.length > 1) { +// // 拆分 +// for (let i = 0; i < textList.length; i++) { +// const t = textList[i]; +// tc.push({ ...item, text: t }); +// } +// } else { +// tc.push(item); +// } +// }); +// richtext.setAttributes({ textConfig: tc }); +// richtext.doUpdateFrameCache(tc); +// } +// } + +// onSelect() { +// return; +// } + +// deactivate(context: IPluginService): void { +// // context.stage.off('pointerdown', this.handleClick); +// context.stage.off('pointermove', this.handleMove); +// context.stage.off('pointerdown', this.handlePointerDown); +// context.stage.off('pointerup', this.handlePointerUp); +// context.stage.off('pointerleave', this.handlePointerUp); +// } + +// release() { +// this.editModule.release(); +// } +// } diff --git a/packages/vrender-core/src/plugins/builtin-plugin/richtext-edit-plugin.ts b/packages/vrender-core/src/plugins/builtin-plugin/richtext-edit-plugin.ts index a4ff463ef..4f9b7ab35 100644 --- a/packages/vrender-core/src/plugins/builtin-plugin/richtext-edit-plugin.ts +++ b/packages/vrender-core/src/plugins/builtin-plugin/richtext-edit-plugin.ts @@ -1,7 +1,7 @@ import type { IPointLike } from '@visactor/vutils'; -import { isObject, isString, merge } from '@visactor/vutils'; +import { isObject, isString, max, merge } from '@visactor/vutils'; import { Generator } from '../../common/generator'; -import { createGroup, createLine, createRect } from '../../graphic'; +import { createGroup, createLine, createRect, RichText } from '../../graphic'; import type { IGroup, ILine, @@ -14,69 +14,90 @@ import type { IRichTextIcon, IRichTextLine, IRichTextParagraph, - IRichTextParagraphCharacter + IRichTextParagraphCharacter, + ITicker, + ITimeline } from '../../interface'; -import { EditModule, findCursorIndexIgnoreLinebreak } from './edit-module'; +import { Animate, DefaultTicker, DefaultTimeline } from '../../animate'; +import { EditModule, findConfigIndexByCursorIdx } from './edit-module'; +import { application } from '../../application'; +import { getWordStartEndIdx } from '../../graphic/richtext/utils'; +// import { testLetter, testLetter2 } from '../../graphic/richtext/utils'; type UpdateType = 'input' | 'change' | 'onfocus' | 'defocus' | 'selection' | 'dispatch'; class Selection { - cacheSelectionStartCursorIdx: number; - cacheCurCursorIdx: number; selectionStartCursorIdx: number; curCursorIdx: number; rt: IRichText; - constructor( - cacheSelectionStartCursorIdx: number, - cacheCurCursorIdx: number, - selectionStartCursorIdx: number, - curCursorIdx: number, - rt: IRichText - ) { + constructor(selectionStartCursorIdx: number, curCursorIdx: number, rt: IRichText) { this.curCursorIdx = curCursorIdx; this.selectionStartCursorIdx = selectionStartCursorIdx; - this.cacheCurCursorIdx = cacheCurCursorIdx; - this.cacheSelectionStartCursorIdx = cacheSelectionStartCursorIdx; this.rt = rt; } + isEmpty(): boolean { + return this.selectionStartCursorIdx === this.curCursorIdx; + } + + getSelectionPureText(): string { + const minCursorIdx = Math.min(this.selectionStartCursorIdx, this.curCursorIdx); + const maxCursorIdx = Math.max(this.selectionStartCursorIdx, this.curCursorIdx); + if (minCursorIdx === maxCursorIdx) { + return ''; + } + const config = this.rt.attribute.textConfig as any; + const startIdx = findConfigIndexByCursorIdx(config, Math.ceil(minCursorIdx)); + const endIdx = findConfigIndexByCursorIdx(config, Math.floor(maxCursorIdx)); + let str = ''; + for (let i = startIdx; i <= endIdx; i++) { + str += config[i].text; + } + return str; + } + hasFormat(key: string): boolean { return this.getFormat(key) != null; } - getFormat(key: string): any { + + /** + * 获取第idx中key的值 + * @param key + * @param cursorIdx + */ + _getFormat(key: string, cursorIdx: number) { if (!this.rt) { return null; } - const config = this.rt.attribute.textConfig; - const val: any = config[this.selectionStartCursorIdx + 1][key]; - if (val == null) { - return null; - } - for (let i = this.selectionStartCursorIdx + 2; i <= this.curCursorIdx; i++) { - const item = config[i]; - if (val === item[key]) { - continue; + let idx = Math.round(cursorIdx); + const config = this.rt.attribute.textConfig as any; + for (let i = 0; i < config.length; i++) { + if (config[i].text !== '\n') { + idx--; + if (idx < 0) { + return config[i][key]; + } } - return null; } - return val; + return config[Math.min(idx, config.length - 1)][key] ?? (this.rt.attribute as any)[key]; + } + getFormat(key: string): any { + return this.getAllFormat(key)[0]; } getAllFormat(key: string): any { - if (!this.rt) { - return []; + const valSet = new Set(); + const minCursorIdx = Math.min(this.selectionStartCursorIdx, this.curCursorIdx); + const maxCursorIdx = Math.max(this.selectionStartCursorIdx, this.curCursorIdx); + if (minCursorIdx === maxCursorIdx) { + return [this._getFormat(key, minCursorIdx)]; } - const config = this.rt.attribute.textConfig; - const val: any = config[this.selectionStartCursorIdx + 1][key]; - const set = new Set(); - set.add(val); - for (let i = this.selectionStartCursorIdx + 2; i <= this.curCursorIdx; i++) { - const item = config[i]; - set.add(item[key]); + for (let i = Math.ceil(minCursorIdx); i <= Math.floor(maxCursorIdx); i++) { + const val = this._getFormat(key, i); + val && valSet.add(val); } - const list = Array.from(set.values()); - return list; + return Array.from(valSet.values()); } } @@ -88,47 +109,80 @@ export class RichTextEditPlugin implements IPlugin { pluginService: IPluginService; _uid: number = Generator.GenAutoIncrementId(); key: string = this.name + this._uid; + + // 是否正在编辑 editing: boolean = false; + // 鼠标是否按下,判断是否展示selection + pointerDown: boolean = false; + + // selection组件 editLine: ILine; editBg: IGroup; - pointerDown: boolean = false; - // 用于selection中保存上一次click时候的位置 - lastPoint?: IPointLike; - editModule: EditModule; + ticker: ITicker; + timeline: ITimeline; + currRt: IRichText; // 当前的cursor信息 + // 0.1为第一个字符右侧, -0.1为第一个字符左侧 + // 1.1为第二个字符右侧,0.9为第二个字符左侧 curCursorIdx: number; selectionStartCursorIdx: number; + startCursorPos?: IPointLike; + + editModule: EditModule; + + protected commandCbs: Map void>>; + protected updateCbs: Array<(type: UpdateType, p: RichTextEditPlugin) => void>; + + // 富文本外部有align或者baseline的时候,需要对光标做偏移 + protected declare deltaX: number; + protected declare deltaY: number; - commandCbs: Map void>>; - updateCbs: Array<(type: UpdateType, p: RichTextEditPlugin) => void>; + // static splitText(text: string) { + // // 😁这种emoji长度算两个,所以得处理一下 + // return Array.from(text); + // } + + static tryUpdateRichtext(richtext: IRichText) { + const cache = richtext.getFrameCache(); + if (!RichText.AllSingleCharacter(cache)) { + const tc = RichText.TransformTextConfig2SingleCharacter(richtext.attribute.textConfig); + // richtext.attribute.textConfig.forEach((item: IRichTextParagraphCharacter) => { + // const textList = RichTextEditPlugin.splitText(item.text.toString()); + // if (isString(item.text) && textList.length > 1) { + // // 拆分 + // for (let i = 0; i < textList.length; i++) { + // const t = textList[i]; + // tc.push({ ...item, text: t }); + // } + // } else { + // tc.push(item); + // } + // }); + richtext.setAttributes({ textConfig: tc }); + richtext.doUpdateFrameCache(tc); + } + } + + static CreateSelection(rt: IRichText) { + if (!rt) { + return null; + } + const { textConfig = [] } = rt.attribute; + return new Selection(0, textConfig.length - 1, rt); + } constructor() { this.commandCbs = new Map(); this.commandCbs.set(FORMAT_TEXT_COMMAND, [this.formatTextCommandCb]); this.updateCbs = []; + this.timeline = new DefaultTimeline(); + this.ticker = new DefaultTicker([this.timeline]); + this.deltaX = 0; + this.deltaY = 0; } - getSelection() { - if ( - this.selectionStartCursorIdx && - this.curCursorIdx && - this.selectionStartCursorIdx !== this.curCursorIdx && - this.currRt - ) { - return new Selection( - this.selectionStartCursorIdx, - this.curCursorIdx, - findCursorIndexIgnoreLinebreak(this.currRt.attribute.textConfig, this.selectionStartCursorIdx), - findCursorIndexIgnoreLinebreak(this.currRt.attribute.textConfig, this.curCursorIdx), - this.currRt - ); - } - return null; - } - - /* command */ formatTextCommandCb(payload: string, p: RichTextEditPlugin) { const rt = p.currRt; if (!rt) { @@ -139,7 +193,11 @@ export class RichTextEditPlugin implements IPlugin { return; } const { selectionStartCursorIdx, curCursorIdx } = selectionData; - const config = rt.attribute.textConfig.slice(selectionStartCursorIdx + 1, curCursorIdx + 1); + const minCursorIdx = Math.min(selectionStartCursorIdx, curCursorIdx); + const maxCursorIdx = Math.max(selectionStartCursorIdx, curCursorIdx); + const minConfigIdx = findConfigIndexByCursorIdx(rt.attribute.textConfig, minCursorIdx); + const maxConfigIdx = findConfigIndexByCursorIdx(rt.attribute.textConfig, maxCursorIdx); + const config = rt.attribute.textConfig.slice(minConfigIdx, maxConfigIdx); if (payload === 'bold') { config.forEach((item: IRichTextParagraphCharacter) => (item.fontWeight = 'bold')); } else if (payload === 'italic') { @@ -178,153 +236,250 @@ export class RichTextEditPlugin implements IPlugin { context.stage.on('pointerdown', this.handlePointerDown); context.stage.on('pointerup', this.handlePointerUp); context.stage.on('pointerleave', this.handlePointerUp); + context.stage.on('dblclick', this.handleDBLClick); + application.global.addEventListener('keydown', this.handleKeyDown); this.editModule.onInput(this.handleInput); this.editModule.onChange(this.handleChange); + this.editModule.onFocusOut(this.handleFocusOut); } - handleInput = (text: string, isComposing: boolean, cursorIdx: number, rt: IRichText, orient: 'left' | 'right') => { - // 修改cursor的位置,但并不同步,因为这可能是临时的 - const p = this.getPointByColumnIdx(cursorIdx, rt, orient); - this.hideSelection(); - this.setCursor(p.x, p.y1, p.y2); - this.updateCbs.forEach(cb => cb('input', this)); - }; - handleChange = (text: string, isComposing: boolean, cursorIdx: number, rt: IRichText, orient: 'left' | 'right') => { - // 修改cursor的位置,并同步到editModule - const p = this.getPointByColumnIdx(cursorIdx, rt, orient); - this.curCursorIdx = cursorIdx; - this.selectionStartCursorIdx = cursorIdx; - this.setCursorAndTextArea(p.x, p.y1, p.y2, rt); - this.hideSelection(); - this.updateCbs.forEach(cb => cb('change', this)); - }; + copyToClipboard(e: KeyboardEvent): boolean { + if ( + (application.global.isMacOS() && e.metaKey && e.key === 'c') || + (!application.global.isMacOS() && e.ctrlKey && e.key === 'c') + ) { + const selection = this.getSelection(); + const text = selection.getSelectionPureText(); + application.global.copyToClipBoard(text); + e.preventDefault(); + return true; + } + return false; + } - handleMove = (e: PointerEvent) => { - if (!this.isRichtext(e)) { + /** + * 选中某一个区间,startIdx和endIdx分别是开始结束的光标位置 + * 设置光标为endIdx,设置开始位置为startIdx + * @param startIdx 开始位置 + * @param endIdx 结束位置 + * @returns + */ + selectionRange(startIdx: number, endIdx: number) { + const currRt = this.currRt; + if (!currRt) { return; } - this.currRt = e.target as IRichText; - this.handleEnter(e); - (e.target as any).once('pointerleave', this.handleLeave); - - this.showSelection(e); - }; - - showSelection(e: PointerEvent) { - const cache = (e.target as IRichText).getFrameCache(); - if (!(cache && this.editBg)) { + const cache = currRt.getFrameCache(); + if (!cache) { return; } - if (this.pointerDown) { - let p0 = this.lastPoint; - // 计算p1在字符中的位置 - let p1 = this.getEventPosition(e); - let line1Info = this.getLineByPoint(cache, p1); - const column1 = this.getColumnByLinePoint(line1Info, p1); - const y1 = line1Info.top; - const y2 = line1Info.top + line1Info.height; - let x = column1.left + column1.width; - let cursorIndex = this.getColumnIndex(cache, column1); - if (p1.x < column1.left + column1.width / 2) { - x = column1.left; - cursorIndex -= 1; + // 对startIdx和endIdx约束 + const { lines } = cache; + const totalCursorCount = lines.reduce((total, line) => total + line.paragraphs.length, 0) - 1; + if (startIdx > endIdx) { + [startIdx, endIdx] = [endIdx, startIdx]; + } + startIdx = Math.min(Math.max(startIdx, -0.1), totalCursorCount + 0.1); + endIdx = Math.min(Math.max(endIdx, -0.1), totalCursorCount + 0.1); + + this.curCursorIdx = endIdx; + this.selectionStartCursorIdx = startIdx; + const { x, y1, y2 } = this.computedCursorPosByCursorIdx(this.selectionStartCursorIdx, this.currRt); + this.startCursorPos = { x, y: (y1 + y2) / 2 }; + const pos = this.computedCursorPosByCursorIdx(this.curCursorIdx, this.currRt); + this.setCursorAndTextArea(pos.x, pos.y1, pos.y2, this.currRt); + this._tryShowSelection(pos, cache); + } + + fullSelection(e: KeyboardEvent) { + if ( + (application.global.isMacOS() && e.metaKey && e.key === 'a') || + (!application.global.isMacOS() && e.ctrlKey && e.key === 'a') + ) { + const currRt = this.currRt; + if (!currRt) { + return; } - p1.x = x; - p1.y = (y1 + y2) / 2; - let line0Info = this.getLineByPoint(cache, p0); - if (p0.y > p1.y || (p0.y === p1.y && p0.x > p1.x)) { - [p0, p1] = [p1, p0]; - [line1Info, line0Info] = [line0Info, line1Info]; + const cache = currRt.getFrameCache(); + if (!cache) { + return; } + const { lines } = cache; + const totalCursorCount = lines.reduce((total, line) => total + line.paragraphs.length, 0) - 1; + this.selectionRange(-0.1, totalCursorCount + 0.1); - this.editBg.removeAllChild(); - if (line0Info === line1Info) { - const column0 = this.getColumnByLinePoint(line0Info, p0); - this.editBg.setAttributes({ - x: p0.x, - y: line0Info.top, - width: p1.x - p0.x, - height: column0.height, - fill: '#336df4', - fillOpacity: 0.2 - }); + e.preventDefault(); + return true; + } + return false; + } + + directKey(e: KeyboardEvent) { + if (!(e.key === 'ArrowUp' || e.key === 'ArrowDown' || e.key === 'ArrowLeft' || e.key === 'ArrowRight')) { + return false; + } + const cache = this.currRt.getFrameCache(); + if (!cache) { + return false; + } + let x = 0; + let y = 0; + if (e.key === 'ArrowUp') { + y = -1; + } else if (e.key === 'ArrowDown') { + y = 1; + } else if (e.key === 'ArrowLeft') { + x = -1; + } else if (e.key === 'ArrowRight') { + x = 1; + } + + // const pos = this.computedCursorPosByCursorIdx(this.curCursorIdx, this.currRt); + const { lineInfo, columnInfo } = this.getColumnByIndex(cache, Math.round(this.curCursorIdx)); + const { lines } = cache; + const totalCursorCount = lines.reduce((total, line) => total + line.paragraphs.length, 0) - 1; + if (x) { + // 快接近首尾需要特殊处理 + if ( + x > 0 && + columnInfo === lineInfo.paragraphs[lineInfo.paragraphs.length - 2] && + this.curCursorIdx < Math.round(this.curCursorIdx) + ) { + this.curCursorIdx = this.curCursorIdx + 0.2; + } else if ( + x > 0 && + columnInfo === lineInfo.paragraphs[lineInfo.paragraphs.length - 1] && + this.curCursorIdx > Math.round(this.curCursorIdx) + ) { + this.curCursorIdx = this.curCursorIdx + 1 - 0.2; + } else if (x < 0 && columnInfo === lineInfo.paragraphs[0] && this.curCursorIdx > Math.round(this.curCursorIdx)) { + this.curCursorIdx = this.curCursorIdx - 0.2; + } else if (x < 0 && columnInfo === lineInfo.paragraphs[0] && this.curCursorIdx < Math.round(this.curCursorIdx)) { + this.curCursorIdx = this.curCursorIdx - 1 + 0.2; } else { - this.editBg.setAttributes({ x: 0, y: line0Info.top, width: 0, height: 0 }); - const startIdx = cache.lines.findIndex(item => item === line0Info); - const endIdx = cache.lines.findIndex(item => item === line1Info); - let y = 0; - for (let i = startIdx; i <= endIdx; i++) { - const line = cache.lines[i]; - if (i === startIdx) { - const p = line.paragraphs[line.paragraphs.length - 1]; - this.editBg.add( - createRect({ - x: p0.x, - y, - width: p.left + p.width - p0.x, - height: line.height, - fill: '#336df4', - fillOpacity: 0.2 - }) - ); - } else if (i === endIdx) { - const p = line.paragraphs[0]; - this.editBg.add( - createRect({ - x: p.left, - y, - width: p1.x - p.left, - height: line.height, - fill: '#336df4', - fillOpacity: 0.2 - }) - ); - } else { - const p0 = line.paragraphs[0]; - const p1 = line.paragraphs[line.paragraphs.length - 1]; - this.editBg.add( - createRect({ - x: p0.left, - y, - width: p1.left + p1.width - p0.left, - height: line.height, - fill: '#336df4', - fillOpacity: 0.2 - }) - ); - } - y += line.height; - } + this.curCursorIdx += x; + } + if (this.curCursorIdx < -0.1) { + this.curCursorIdx = -0.1; + } else if (this.curCursorIdx > totalCursorCount + 0.1) { + this.curCursorIdx = totalCursorCount + 0.1; } - this.curCursorIdx = cursorIndex; - this.setCursorAndTextArea(x, y1 + 2, y2 - 2, e.target as IRichText); - - this.applyUpdate(); - this.updateCbs.forEach(cb => cb('selection', this)); + const pos = this.computedCursorPosByCursorIdx(this.curCursorIdx, this.currRt); + this.setCursorAndTextArea(pos.x, pos.y1, pos.y2, this.currRt); + this.hideSelection(); } - } - hideSelection() { - if (this.editBg) { - this.editBg.removeAllChild(); - this.editBg.setAttributes({ fill: 'transparent' }); + if (y) { + if (y > 0 && lineInfo === cache.lines[cache.lines.length - 1]) { + return; + } + if (y < 0 && lineInfo === cache.lines[0]) { + return; + } + const lineIdx = cache.lines.findIndex(item => item === lineInfo) + y; + if (lineIdx < 0 || lineIdx >= cache.lines.length) { + return; + } + const pos = this.computedCursorPosByCursorIdx(this.curCursorIdx, this.currRt); + const posX = pos.x; + let posY = (pos.y1 + pos.y2) / 2; + posY += y * lineInfo.height; + const nextLineInfo = cache.lines[lineIdx]; + const { columnInfo, delta } = this.getColumnAndIndexByLinePoint(nextLineInfo, { x: posX, y: posY }); + if (!columnInfo) { + return; + } + let cursorIdx = this.getColumnIndex(cache, columnInfo) + delta; + const data = this.computedCursorPosByCursorIdx(cursorIdx, this.currRt); + + if (cursorIdx < -0.1) { + cursorIdx = -0.1; + } else if (cursorIdx > totalCursorCount + 0.1) { + cursorIdx = totalCursorCount + 0.1; + } + + this.curCursorIdx = cursorIdx; + this.selectionStartCursorIdx = cursorIdx; + this.setCursorAndTextArea(data.x, data.y1, data.y2, this.currRt); } + + return true; } - handlePointerDown = (e: PointerEvent) => { - if (this.editing) { - this.onFocus(e); - } else { - this.deFocus(e); + handleKeyDown = (e: KeyboardEvent) => { + if (!(this.currRt && this.editing)) { + return; + } + // 复制到剪贴板 + // cmd/ctl + C + if (this.copyToClipboard(e)) { + return; + } + // 全选 + // cmd/ctl + A + if (this.fullSelection(e)) { + return; + } + // 方向键 + // 上、下、左、右 + if (this.directKey(e)) { + return; } - this.applyUpdate(); - this.pointerDown = true; - this.updateCbs.forEach(cb => cb(this.editing ? 'onfocus' : 'defocus', this)); }; - handlePointerUp = (e: PointerEvent) => { + + handleInput = (text: string, isComposing: boolean, cursorIdx: number, rt: IRichText) => { + // 修改cursor的位置,但并不同步到curIdx,因为这可能是临时的 + // const p = this.getPointByColumnIdx(cursorIdx, rt, orient); + // console.log(this.curCursorIdx, cursorIdx); + this.hideSelection(); + // this.setCursor(p.x, p.y1, p.y2); + this.updateCbs.forEach(cb => cb('input', this)); + }; + + handleChange = (text: string, isComposing: boolean, cursorIdx: number, rt: IRichText) => { + // 修改cursor的位置,并同步到editModule + this.curCursorIdx = cursorIdx; + this.selectionStartCursorIdx = cursorIdx; + const p = this.computedCursorPosByCursorIdx(cursorIdx, rt); + this.setCursorAndTextArea(p.x, p.y1, p.y2, rt); + this.hideSelection(); + this.updateCbs.forEach(cb => cb('change', this)); + }; + + handleFocusIn = () => { + // this.updateCbs.forEach(cb => cb(this.editing ? 'onfocus' : 'defocus', this)); + }; + + handleFocusOut = () => { + this.editing = false; + this.deFocus(); this.pointerDown = false; + this.triggerRender(); + this.updateCbs.forEach(cb => cb('defocus', this)); + }; + + deactivate(context: IPluginService): void { + // context.stage.off('pointerdown', this.handleClick); + context.stage.off('pointermove', this.handleMove); + context.stage.off('pointerdown', this.handlePointerDown); + context.stage.off('pointerup', this.handlePointerUp); + context.stage.off('pointerleave', this.handlePointerUp); + context.stage.off('dblclick', this.handleDBLClick); + + application.global.addEventListener('keydown', this.handleKeyDown); + } + + handleMove = (e: PointerEvent) => { + if (!this.isRichtext(e)) { + return; + } + this.currRt = e.target as IRichText; + this.handleEnter(e); + (e.target as any).once('pointerleave', this.handleLeave); + + this.tryShowSelection(e, false); }; // 鼠标进入 @@ -339,63 +494,50 @@ export class RichTextEditPlugin implements IPlugin { this.pluginService.stage.setCursor('default'); }; - isRichtext(e: PointerEvent) { - return !!(e.target && (e.target as any).type === 'richtext' && (e.target as any).attribute.editable); - } - - protected getEventPosition(e: PointerEvent): IPointLike { - const p = this.pluginService.stage.eventPointTransform(e); - - const p1 = { x: 0, y: 0 }; - (e.target as IRichText).globalTransMatrix.transformPoint(p, p1); - return p1; - } - - protected getLineByPoint(cache: IRichTextFrame, p1: IPointLike): IRichTextLine { - let lineInfo = cache.lines[0]; - for (let i = 0; i < cache.lines.length; i++) { - if (lineInfo.top <= p1.y && lineInfo.top + lineInfo.height >= p1.y) { - break; - } - lineInfo = cache.lines[i + 1]; + handlePointerDown = (e: PointerEvent) => { + if (this.editing) { + this.onFocus(e); + } else { + this.deFocus(); } - - return lineInfo; - } - protected getColumnByLinePoint(lineInfo: IRichTextLine, p1: IPointLike): IRichTextParagraph | IRichTextIcon { - let columnInfo = lineInfo.paragraphs[0]; - for (let i = 0; i < lineInfo.paragraphs.length; i++) { - if (columnInfo.left <= p1.x && columnInfo.left + columnInfo.width >= p1.x) { - break; - } - columnInfo = lineInfo.paragraphs[i]; + this.triggerRender(); + this.pointerDown = true; + this.updateCbs.forEach(cb => cb(this.editing ? 'onfocus' : 'defocus', this)); + }; + handlePointerUp = (e: PointerEvent) => { + this.pointerDown = false; + }; + handleDBLClick = (e: PointerEvent) => { + if (!this.editing) { + return; } - return columnInfo; - } + this.tryShowSelection(e, true); + }; onFocus(e: PointerEvent) { - this.deFocus(e); + this.deFocus(); + this.currRt = e.target as IRichText; - // 添加shadowGraphic + // 创建shadowGraphic const target = e.target as IRichText; - this.tryUpdateRichtext(target); + RichTextEditPlugin.tryUpdateRichtext(target); const shadowRoot = target.attachShadow(); - shadowRoot.setAttributes({ shadowRootIdx: -1 }); const cache = target.getFrameCache(); if (!cache) { return; } + // 计算全局偏移 + this.computeGlobalDelta(cache); + + // 添加cursor节点,shadowRoot在上面 + shadowRoot.setAttributes({ shadowRootIdx: 1, pickable: false, x: this.deltaX, y: this.deltaY }); if (!this.editLine) { const line = createLine({ x: 0, y: 0, lineWidth: 1, stroke: 'black' }); - line - .animate() - .to({ opacity: 1 }, 10, 'linear') - .wait(700) - .to({ opacity: 0 }, 10, 'linear') - .wait(700) - .loop(Infinity); + // 不使用stage的Ticker,避免影响其他的动画以及受到其他动画影响 + this.addAnimateToLine(line); this.editLine = line; + this.ticker.start(true); const g = createGroup({ x: 0, y: 0, width: 0, height: 0 }); this.editBg = g; @@ -403,49 +545,249 @@ export class RichTextEditPlugin implements IPlugin { shadowRoot.add(this.editBg); } - const p1 = this.getEventPosition(e); + const data = this.computedCursorPosByEvent(e, cache); - const lineInfo = this.getLineByPoint(cache, p1); + if (data) { + const { x, y1, y2, cursorIndex } = data; + this.startCursorPos = { x, y: (y1 + y2) / 2 }; + this.curCursorIdx = cursorIndex; + this.selectionStartCursorIdx = cursorIndex; + this.setCursorAndTextArea(x, y1, y2, target); + } + } - if (lineInfo) { - const columnInfo = this.getColumnByLinePoint(lineInfo, p1); - if (!columnInfo) { + protected deFocus() { + const target = this.currRt as IRichText; + if (!target) { + return; + } + target.detachShadow(); + this.currRt = null; + if (this.editLine) { + this.editLine.parent.removeChild(this.editLine); + this.editLine.release(); + this.editLine = null; + + this.editBg.parent.removeChild(this.editBg); + this.editBg.release(); + this.editBg = null; + } + } + + protected addAnimateToLine(line: ILine) { + line.animates && + line.animates.forEach(animate => { + animate.stop(); + animate.release(); + }); + const animate = line.animate(); + animate.setTimeline(this.timeline); + animate.to({ opacity: 1 }, 10, 'linear').wait(700).to({ opacity: 0 }, 10, 'linear').wait(700).loop(Infinity); + } + + // 显示selection + tryShowSelection(e: PointerEvent, dblclick: boolean) { + const cache = (e.target as IRichText).getFrameCache(); + if (!(cache && this.editBg && this.startCursorPos)) { + return; + } + + if (!dblclick) { + if (this.pointerDown) { + const currCursorData = this.computedCursorPosByEvent(e, cache); + if (!currCursorData) { + return; + } + this.curCursorIdx = currCursorData.cursorIndex; + this._tryShowSelection(currCursorData, cache); + } + } else { + const currCursorData = this.computedCursorPosByEvent(e, cache); + if (!currCursorData) { + return; + } + // const curCursorIdx = currCursorData.cursorIndex; + const lineInfo = currCursorData.lineInfo; + const columnIndex = lineInfo.paragraphs.findIndex(item => item === currCursorData.columnInfo); + if (columnIndex < 0) { return; } + const str = lineInfo.paragraphs.reduce((str, item) => { + return str + item.text; + }, ''); + + let idx = 0; + for (let i = 0; i < cache.lines.length; i++) { + const line = cache.lines[i]; + if (line === lineInfo) { + break; + } + idx += line.paragraphs.length; + } + + const { startIdx, endIdx } = getWordStartEndIdx(str, columnIndex); - let y1 = lineInfo.top; - let y2 = lineInfo.top + lineInfo.height; - let x = columnInfo.left + columnInfo.width; - y1 += 2; - y2 -= 2; - let cursorIndex = this.getColumnIndex(cache, columnInfo); - if (p1.x < columnInfo.left + columnInfo.width / 2) { - x = columnInfo.left; - cursorIndex -= 1; + this.selectionRange(idx + startIdx - 0.1, idx + endIdx - 0.1); + } + } + + _tryShowSelection( + currCursorData: { + x: any; + y1: number; + y2: number; + }, + cache: IRichTextFrame + ) { + let startCursorPos = this.startCursorPos; + let endCursorPos = { + x: currCursorData.x, + y: (currCursorData.y1 + currCursorData.y2) / 2 + }; + let line0Info = this.getLineByPoint(cache, startCursorPos); + let line1Info = this.getLineByPoint(cache, endCursorPos); + + if ( + startCursorPos.y > endCursorPos.y || + (startCursorPos.y === endCursorPos.y && startCursorPos.x > endCursorPos.x) + ) { + [startCursorPos, endCursorPos] = [endCursorPos, startCursorPos]; + [line1Info, line0Info] = [line0Info, line1Info]; + } + + this.hideSelection(); + if (line0Info === line1Info) { + // 同行 + this.editBg.setAttributes({ + x: startCursorPos.x, + y: line0Info.top, + width: endCursorPos.x - startCursorPos.x, + height: line0Info.height, + fill: '#336df4', + fillOpacity: 0.2 + }); + } else { + this.editBg.setAttributes({ x: 0, y: line0Info.top, width: 0, height: 0 }); + const startIdx = cache.lines.findIndex(item => item === line0Info); + const endIdx = cache.lines.findIndex(item => item === line1Info); + let y = 0; + for (let i = startIdx; i <= endIdx; i++) { + const line = cache.lines[i]; + if (i === startIdx) { + const p = line.paragraphs[line.paragraphs.length - 1]; + this.editBg.add( + createRect({ + x: startCursorPos.x, + y, + width: p.left + p.width - startCursorPos.x, + height: line.height, + fill: '#336df4', + fillOpacity: 0.2 + }) + ); + } else if (i === endIdx) { + const p = line.paragraphs[0]; + this.editBg.add( + createRect({ + x: p.left, + y, + width: endCursorPos.x - p.left, + height: line.height, + fill: '#336df4', + fillOpacity: 0.2 + }) + ); + } else { + const p0 = line.paragraphs[0]; + const p1 = line.paragraphs[line.paragraphs.length - 1]; + this.editBg.add( + createRect({ + x: p0.left, + y, + width: p1.left + p1.width - p0.left, + height: line.height, + fill: '#336df4', + fillOpacity: 0.2 + }) + ); + } + y += line.height; } + } - this.lastPoint = { x, y: (y1 + y2) / 2 }; + this.setCursorAndTextArea(currCursorData.x, currCursorData.y1 + 2, currCursorData.y2 - 2, this.currRt as IRichText); - this.curCursorIdx = cursorIndex; - this.selectionStartCursorIdx = cursorIndex; - this.setCursorAndTextArea(x, y1, y2, target); + this.triggerRender(); + this.updateCbs.forEach(cb => cb('selection', this)); + } + + hideSelection() { + if (this.editBg) { + this.editBg.removeAllChild(); + this.editBg.setAttributes({ fill: 'transparent' }); } } - protected getPointByColumnIdx(idx: number, rt: IRichText, orient: 'left' | 'right') { - const cache = rt.getFrameCache(); - const { lineInfo, columnInfo } = this.getColumnByIndex(cache, idx); - let y1 = lineInfo.top; - let y2 = lineInfo.top + lineInfo.height; - const x = columnInfo.left + (orient === 'left' ? 0 : columnInfo.width); - y1 += 2; - y2 -= 2; + protected getLineByPoint(cache: IRichTextFrame, p1: IPointLike): IRichTextLine { + let lineInfo = cache.lines[0]; + for (let i = 0; i < cache.lines.length; i++) { + if (lineInfo.top <= p1.y && lineInfo.top + lineInfo.height >= p1.y) { + break; + } + lineInfo = cache.lines[i + 1]; + } - return { x, y1, y2 }; + return lineInfo; } + protected getColumnAndIndexByLinePoint( + lineInfo: IRichTextLine, + p1: IPointLike + ): { + columnInfo: IRichTextParagraph | IRichTextIcon; + delta: number; + } { + let columnInfo = lineInfo.paragraphs[0]; + let delta = 0; + if (lineInfo.paragraphs.length) { + const start = lineInfo.paragraphs[0]; + const end = lineInfo.paragraphs[lineInfo.paragraphs.length - 1]; + if (p1.x <= start.left) { + delta = -0.1; + columnInfo = start; + } else if (p1.x >= end.left + end.width) { + delta = 0.1; + columnInfo = end; + } + } + if (!delta) { + for (let i = 0; i < lineInfo.paragraphs.length; i++) { + columnInfo = lineInfo.paragraphs[i]; + if (columnInfo.left <= p1.x && columnInfo.left + columnInfo.width >= p1.x) { + if (p1.x > columnInfo.left + columnInfo.width / 2) { + delta = 0.1; + } else { + delta = -0.1; + } + break; + } + } + } + + return { + columnInfo, + delta + }; + } + /* 工具函数 */ + /** + * 根据给定的ParagraphInfo得到对应的index + * @param cache 富文本缓存 + * @param cInfo ParagraphInfo + * @returns + */ protected getColumnIndex(cache: IRichTextFrame, cInfo: IRichTextParagraph | IRichTextIcon) { - // TODO 认为都是单个字符拆分的 + // TODO 【注意】认为cache都是单个字符拆分的 let inputIndex = -1; for (let i = 0; i < cache.lines.length; i++) { const line = cache.lines[i]; @@ -458,29 +800,42 @@ export class RichTextEditPlugin implements IPlugin { } return -1; } - protected getColumnByIndex( - cache: IRichTextFrame, - index: number - ): { - lineInfo: IRichTextLine; - columnInfo: IRichTextParagraph | IRichTextIcon; - } | null { - // TODO 认为都是单个字符拆分的 - let inputIndex = -1; - for (let i = 0; i < cache.lines.length; i++) { - const lineInfo = cache.lines[i]; - for (let j = 0; j < lineInfo.paragraphs.length; j++) { - const columnInfo = lineInfo.paragraphs[j]; - inputIndex++; - if (inputIndex === index) { - return { - lineInfo, - columnInfo - }; - } - } + + protected isRichtext(e: PointerEvent) { + return !!(e.target && (e.target as any).type === 'richtext' && (e.target as any).attribute.editable); + } + + // 如果没有开自动渲染,得触发重绘 + protected triggerRender() { + this.pluginService.stage.renderNextFrame(); + } + + protected computeGlobalDelta(cache: IRichTextFrame) { + this.deltaX = 0; + this.deltaY = 0; + const height = cache.height; + const actualHeight = cache.actualHeight; + const width = cache.lines.reduce((w, item) => Math.max(w, item.actualWidth), 0); + if (cache.globalAlign === 'center') { + this.deltaX = -width / 2; + } else if (cache.globalAlign === 'right') { + this.deltaX = -width; + } + if (cache.verticalDirection === 'middle') { + this.deltaY = height / 2 - actualHeight / 2; + } else if (cache.verticalDirection === 'bottom') { + this.deltaY = height - actualHeight; } - return null; + } + + protected getEventPosition(e: PointerEvent): IPointLike { + const p = this.pluginService.stage.eventPointTransform(e); + + const p1 = { x: 0, y: 0 }; + (e.target as IRichText).globalTransMatrix.transformPoint(p, p1); + p1.x -= this.deltaX; + p1.y -= this.deltaY; + return p1; } protected setCursorAndTextArea(x: number, y1: number, y2: number, rt: IRichText) { @@ -490,6 +845,7 @@ export class RichTextEditPlugin implements IPlugin { { x, y: y2 } ] }); + this.addAnimateToLine(this.editLine); const out = { x: 0, y: 0 }; rt.globalTransMatrix.getInverse().transformPoint({ x, y: y1 }, out); // TODO 考虑stage变换 @@ -499,79 +855,126 @@ export class RichTextEditPlugin implements IPlugin { this.editModule.moveTo(out.x, out.y, rt, this.curCursorIdx, this.selectionStartCursorIdx); } - protected setCursor(x: number, y1: number, y2: number) { - this.editLine.setAttributes({ - points: [ - { x, y: y1 }, - { x, y: y2 } - ] - }); - } - applyUpdate() { - this.pluginService.stage.renderNextFrame(); - } - deFocus(e: PointerEvent) { - const target = this.currRt as IRichText; - if (!target) { + /** + * 根据Event算出光标位置等信息 + * @param e Event + * @param cache 富文本缓存 + * @returns + */ + protected computedCursorPosByEvent(e: PointerEvent, cache: IRichTextFrame) { + const p1 = this.getEventPosition(e); + const lineInfo = this.getLineByPoint(cache, p1); + if (!lineInfo) { return; } - target.detachShadow(); - this.currRt = null; - if (this.editLine) { - this.editLine.parent.removeChild(this.editLine); - this.editLine.release(); - this.editLine = null; - this.editBg.parent.removeChild(this.editBg); - this.editBg.release(); - this.editBg = null; + const { columnInfo, delta } = this.getColumnAndIndexByLinePoint(lineInfo, p1); + if (!columnInfo) { + return; } - } - splitText(text: string) { - // 😁这种emoji长度算两个,所以得处理一下 - return Array.from(text); + let y1 = lineInfo.top; + let y2 = lineInfo.top + lineInfo.height; + y1 += 2; + y2 -= 2; + + let cursorIndex = this.getColumnIndex(cache, columnInfo); + cursorIndex += delta; + const x = columnInfo.left + (delta > 0 ? columnInfo.width : 0); + + return { + x, + y1, + y2, + cursorIndex, + lineInfo, + columnInfo + }; } - tryUpdateRichtext(richtext: IRichText) { - const cache = richtext.getFrameCache(); - if ( - !cache.lines.every(line => - line.paragraphs.every(item => !(item.text && isString(item.text) && this.splitText(item.text).length > 1)) - ) - ) { - const tc: IRichTextCharacter[] = []; - richtext.attribute.textConfig.forEach((item: IRichTextParagraphCharacter) => { - const textList = this.splitText(item.text.toString()); - if (isString(item.text) && textList.length > 1) { - // 拆分 - for (let i = 0; i < textList.length; i++) { - const t = textList[i]; - tc.push({ ...item, text: t }); - } - } else { - tc.push(item); - } - }); - richtext.setAttributes({ textConfig: tc }); - richtext.doUpdateFrameCache(tc); + /** + * 根据cursorIdx计算出点的位置 + * @param cursorIdx index + * @param rt 富文本 + * @returns + */ + protected computedCursorPosByCursorIdx(cursorIdx: number, rt: IRichText) { + const idx = Math.round(cursorIdx); + const leftRight = cursorIdx - idx; // >0 向右,<0 向左 + const cache = rt.getFrameCache(); + const column = this.getColumnByIndex(cache, idx); + const height = rt.attribute.fontSize ?? (rt.attribute.textConfig?.[0] as any)?.fontSize; + if (!column) { + return { + x: 0, + y1: 0, + y2: height + }; } - } + const { lineInfo, columnInfo } = column; + let y1 = lineInfo.top; + let y2 = lineInfo.top + lineInfo.height; + const x = columnInfo.left + (leftRight < 0 ? 0 : columnInfo.width); + y1 += 2; + y2 -= 2; - onSelect() { - return; + return { x, y1, y2 }; } - deactivate(context: IPluginService): void { - // context.stage.off('pointerdown', this.handleClick); - context.stage.off('pointermove', this.handleMove); - context.stage.off('pointerdown', this.handlePointerDown); - context.stage.off('pointerup', this.handlePointerUp); - context.stage.off('pointerleave', this.handlePointerUp); + /** + * 根据index获取columnInfo + * @param cache 缓存 + * @param index index + * @returns + */ + protected getColumnByIndex( + cache: IRichTextFrame, + index: number + ): { + lineInfo: IRichTextLine; + columnInfo: IRichTextParagraph | IRichTextIcon; + } | null { + // TODO 认为都是单个字符拆分的 + for (let i = 0, inputIndex = 0; i < cache.lines.length; i++) { + const lineInfo = cache.lines[i]; + for (let j = 0; j < lineInfo.paragraphs.length; j++) { + const columnInfo = lineInfo.paragraphs[j]; + if (inputIndex === index) { + return { + lineInfo, + columnInfo + }; + } + inputIndex++; + } + } + return null; } release() { + this.deactivate(this.pluginService); this.editModule.release(); } + + /** + * 获取当前选择的区间范围 + * @param defaultAll 如果force为true,又没有选择,则认为选择了所有然后进行匹配,如果为false,则认为什么都没有选择,返回null + * @returns + */ + getSelection(defaultAll: boolean = false) { + if (!this.currRt) { + return null; + } + if ( + this.selectionStartCursorIdx != null && + this.curCursorIdx != null + // this.selectionStartCursorIdx !== this.curCursorIdx && + ) { + return new Selection(this.selectionStartCursorIdx, this.curCursorIdx, this.currRt); + } else if (defaultAll) { + return RichTextEditPlugin.CreateSelection(this.currRt); + } + return null; + } } diff --git a/packages/vrender-kits/src/env/contributions/browser-contribution.ts b/packages/vrender-kits/src/env/contributions/browser-contribution.ts index 82fd1f493..65e73d100 100644 --- a/packages/vrender-kits/src/env/contributions/browser-contribution.ts +++ b/packages/vrender-kits/src/env/contributions/browser-contribution.ts @@ -62,6 +62,7 @@ export function createImageElement(src: string, isSvg: boolean = false): Promise export class BrowserEnvContribution extends BaseEnvContribution implements IEnvContribution { type: EnvType = 'browser'; supportEvent: boolean = true; + _isMacOS?: boolean; constructor() { super(); @@ -372,4 +373,26 @@ export class BrowserEnvContribution extends BaseEnvContribution implements IEnvC return { loadState: 'fail' } as { loadState: 'success' | 'fail' }; }); } + + isMacOS(): boolean { + if (this._isMacOS === void 0) { + try { + this._isMacOS = navigator.platform.toUpperCase().indexOf('MAC') >= 0; + } catch (err) { + this._isMacOS = false; + } + } + return this._isMacOS; + } + + copyToClipBoard(text: string): Promise { + return navigator.clipboard + .writeText(text) + .then(() => { + return; + }) + .catch(err => { + return; + }); + } } diff --git a/packages/vrender/__tests__/browser/src/pages/richtext-editor.ts b/packages/vrender/__tests__/browser/src/pages/richtext-editor.ts index 37485310b..99c1d1cb4 100644 --- a/packages/vrender/__tests__/browser/src/pages/richtext-editor.ts +++ b/packages/vrender/__tests__/browser/src/pages/richtext-editor.ts @@ -28,9 +28,10 @@ export const page = () => { shapes.push( createRichText({ visible: true, - fontSize: 26, + fontSize: 16, _debug_bounds: true, width: 0, + height: 0, x: 100, y: 100, editable: true, @@ -39,211 +40,264 @@ export const page = () => { textConfig: [ { text: '我', - fontSize: 26, + fontSize: 16, + lineHeight: 26, textAlign: 'center', + background: 'orange', fill: '#0f51b5' }, { text: '们', - fontSize: 26, + fontSize: 16, + lineHeight: 26, textAlign: 'center', + background: 'orange', fill: '#0f51b5' }, { text: '是', - fontSize: 26, + fontSize: 16, + lineHeight: 26, textAlign: 'center', + background: 'orange', fill: '#0f51b5' }, { text: '无', - fontSize: 26, + fontSize: 16, + lineHeight: 26, textAlign: 'center', fill: '#0f51b5' }, { text: '缘', - fontSize: 26, + fontSize: 16, + lineHeight: 26, textAlign: 'center', fill: '#0f51b5' }, { text: 'a', - fontSize: 26, + fontSize: 16, + lineHeight: 26, textAlign: 'center', fill: '#0f51b5' }, { text: '无', - fontSize: 26, + fontSize: 16, + lineHeight: 26, textAlign: 'center', fill: '#0f51b5' }, { text: '故', - fontSize: 26, + fontSize: 16, + lineHeight: 26, textAlign: 'center', fill: '#0f51b5' }, { text: '的', - fontSize: 26, + fontSize: 16, + lineHeight: 26, textAlign: 'center', fill: '#0f51b5' }, { text: '尘😁', - fontSize: 26, + fontSize: 16, + lineHeight: 26, textAlign: 'center', fill: '#0f51b5' }, { text: '埃\n', - fontSize: 26, + fontSize: 16, + lineHeight: 26, textAlign: 'center', fill: '#0f51b5' }, { text: '无', - fontSize: 26, + fontSize: 16, + lineHeight: 26, textAlign: 'center', fill: '#0f51b5' }, { text: '缘', - fontSize: 26, + fontSize: 16, + lineHeight: 26, textAlign: 'center', fill: '#0f51b5' }, { text: '无', - fontSize: 26, + fontSize: 16, + lineHeight: 26, textAlign: 'center', fill: '#0f51b5' }, { text: '故', - fontSize: 26, + fontSize: 16, + lineHeight: 26, textAlign: 'center', fill: '#0f51b5' }, { text: '的', - fontSize: 26, + fontSize: 16, + lineHeight: 26, textAlign: 'center', fill: '#0f51b5' }, { text: '游', - fontSize: 26, + fontSize: 16, + lineHeight: 26, textAlign: 'center', fill: '#0f51b5' }, { text: '走\n', - fontSize: 26, + fontSize: 16, + lineHeight: 26, textAlign: 'center', fill: '#0f51b5' }, { text: '黑', - fontSize: 26, + fontSize: 16, + lineHeight: 26, textAlign: 'center', fill: '#0f51b5' }, { text: '暗', - fontSize: 26, + fontSize: 16, + lineHeight: 26, textAlign: 'center', fill: '#0f51b5' }, { text: '只', - fontSize: 26, + fontSize: 16, + lineHeight: 26, textAlign: 'center', fill: '#0f51b5' }, { text: '需', - fontSize: 26, + fontSize: 16, + lineHeight: 26, textAlign: 'center', fill: '#0f51b5' }, { text: '要', - fontSize: 26, + fontSize: 16, + lineHeight: 26, textAlign: 'center', fill: '#0f51b5' }, { text: '张', - fontSize: 26, + fontSize: 16, + lineHeight: 26, textAlign: 'center', fill: '#0f51b5' }, { text: '开', - fontSize: 26, + fontSize: 16, + lineHeight: 26, textAlign: 'center', fill: '#0f51b5' }, { text: '一', - fontSize: 26, + fontSize: 16, + lineHeight: 26, textAlign: 'center', fill: '#0f51b5' }, { text: '张', - fontSize: 26, + fontSize: 16, + lineHeight: 26, textAlign: 'center', fill: '#0f51b5' }, { text: '缝', - fontSize: 26, + fontSize: 16, + lineHeight: 26, textAlign: 'center', fill: '#0f51b5' }, { text: '隙\n', - fontSize: 26, + fontSize: 16, + lineHeight: 26, textAlign: 'center', fill: '#0f51b5' }, { text: '就', - fontSize: 26, + fontSize: 16, + lineHeight: 26, textAlign: 'center', fill: '#0f51b5' }, { text: '能', - fontSize: 26, + fontSize: 16, + lineHeight: 26, textAlign: 'center', fill: '#0f51b5' }, { text: '挂', - fontSize: 26, + fontSize: 16, + lineHeight: 26, textAlign: 'center', fill: '#0f51b5' }, { text: '起', - fontSize: 26, + fontSize: 16, + lineHeight: 26, textAlign: 'center', fill: '#0f51b5' }, { text: '飓', - fontSize: 26, + fontSize: 16, + lineHeight: 26, + textAlign: 'center', + fill: '#0f51b5' + }, + { + text: '[4]', + script: 'super', + fontSize: 16, + lineHeight: 26, textAlign: 'center', fill: '#0f51b5' }, { text: '风\n', - fontSize: 26, + fontSize: 16, + lineHeight: 26, + textAlign: 'center', + fill: '#0f51b5' + }, + { + text: 'and this is our world, \nthat we call life', + fontSize: 16, + lineHeight: 26, textAlign: 'center', fill: '#0f51b5' } @@ -251,6 +305,8 @@ export const page = () => { }) ); + console.log(shapes[0]); + const stage = createStage({ canvas: 'main', width: 1200, @@ -282,7 +338,7 @@ export const page = () => { } }); - ['bold', 'italic', 'underline', 'lineThrough', { fill: 'red' }].forEach(item => { + ['bold', 'italic', 'underline', 'lineThrough', { fill: 'red' }, { background: 'pink' }].forEach(item => { const btn = document.createElement('button'); btn.innerHTML = typeof item === 'string' ? item : JSON.stringify(item); btn.addEventListener('click', () => {