From 0b2db0270c452f87902096ba454eb066ce905549 Mon Sep 17 00:00:00 2001 From: unanmed <1319491857@qq.com> Date: Thu, 27 Feb 2025 22:33:16 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20ConfirmBox=20=E7=BB=84=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/core/render/preset/misc.ts | 22 ++- src/core/render/render.ts | 4 +- src/module/render/components/choices.tsx | 168 +++++++++++++++++-- src/module/render/components/misc.tsx | 11 +- src/module/render/components/textbox.tsx | 10 +- src/module/render/components/textboxTyper.ts | 111 ++++++++++-- 6 files changed, 293 insertions(+), 33 deletions(-) diff --git a/src/core/render/preset/misc.ts b/src/core/render/preset/misc.ts index 6503a92..acf340c 100644 --- a/src/core/render/preset/misc.ts +++ b/src/core/render/preset/misc.ts @@ -7,6 +7,9 @@ import { isNil } from 'lodash-es'; import { logger } from '@/core/common/logger'; import { IAnimateFrame, renderEmits } from '../frame'; +/** 文字的安全填充,会填充在文字的上侧和下侧,防止削顶和削底 */ +const SAFE_PAD = 1; + type CanvasStyle = string | CanvasGradient | CanvasPattern; export interface ETextEvent extends ERenderItemEvent { @@ -31,8 +34,10 @@ export class Text extends RenderItem { this.text = text; if (text.length > 0) { - this.calBox(); - this.emit('setText', text, this.width, this.height); + this.requestBeforeFrame(() => { + this.calBox(); + this.emit('setText', text, this.width, this.height); + }); } } @@ -41,6 +46,7 @@ export class Text extends RenderItem { _transform: Transform ): void { const ctx = canvas.ctx; + const stroke = this.strokeWidth; ctx.textBaseline = 'bottom'; ctx.fillStyle = this.fillStyle ?? 'transparent'; ctx.strokeStyle = this.strokeStyle ?? 'transparent'; @@ -48,10 +54,10 @@ export class Text extends RenderItem { ctx.lineWidth = this.strokeWidth; if (this.strokeStyle) { - ctx.strokeText(this.text, 0, this.descent); + ctx.strokeText(this.text, stroke, this.descent + stroke + SAFE_PAD); } if (this.fillStyle) { - ctx.fillText(this.text, 0, this.descent); + ctx.fillText(this.text, stroke, this.descent + stroke + SAFE_PAD); } } @@ -95,6 +101,7 @@ export class Text extends RenderItem { setStyle(fill?: CanvasStyle, stroke?: CanvasStyle) { this.fillStyle = fill; this.strokeStyle = stroke; + this.update(); } /** @@ -102,7 +109,11 @@ export class Text extends RenderItem { * @param width 宽度 */ setStrokeWidth(width: number) { + const before = this.strokeWidth; this.strokeWidth = width; + const dw = width - before; + this.size(this.width + dw * 2, this.height + dw * 2); + this.update(); } /** @@ -113,7 +124,8 @@ export class Text extends RenderItem { this.measure(); this.length = width; this.descent = actualBoundingBoxAscent; - this.size(width, actualBoundingBoxAscent + actualBoundingBoxDescent); + const height = actualBoundingBoxAscent + actualBoundingBoxDescent; + this.size(width, height + this.strokeWidth * 2 + SAFE_PAD * 2); } protected handleProps( diff --git a/src/core/render/render.ts b/src/core/render/render.ts index 56e0a28..2ea7794 100644 --- a/src/core/render/render.ts +++ b/src/core/render/render.ts @@ -564,10 +564,10 @@ export class MotaRenderer extends Container implements IRenderTreeRoot { private toTagString(item: RenderItem, space: number, deep: number): string { const name = item.constructor.name; if (item.children.size === 0) { - return `${' '.repeat(deep * space)}<${name} id="${item.id}" type="${item.type}"${item.hidden ? ' hidden' : ''}>\n`; + return `${' '.repeat(deep * space)}<${name} id="${item.id}" uid="${item.uid}" type="${item.type}"${item.hidden ? ' hidden' : ''}>\n`; } else { return ( - `${' '.repeat(deep * space)}<${name} id="${item.id}" type="${item.type}" ${item.hidden ? 'hidden' : ''}>\n` + + `${' '.repeat(deep * space)}<${name} id="${item.id}" uid="${item.uid}" type="${item.type}" ${item.hidden ? 'hidden' : ''}>\n` + `${[...item.children].map(v => this.toTagString(v, space, deep + 1)).join('')}` + `${' '.repeat(deep * space)}\n` ); diff --git a/src/module/render/components/choices.tsx b/src/module/render/components/choices.tsx index 14e6c49..e71312a 100644 --- a/src/module/render/components/choices.tsx +++ b/src/module/render/components/choices.tsx @@ -1,18 +1,164 @@ -import { DefaultProps } from '@/core/render'; -import { defineComponent } from 'vue'; +import { DefaultProps, ElementLocator, useKey } from '@/core/render'; +import { computed, defineComponent, ref } from 'vue'; +import { Background, Selection } from './misc'; +import { TextContent, TextContentExpose, TextContentProps } from './textbox'; +import { SetupComponentOptions } from './types'; +import { TextAlign } from './textboxTyper'; -export interface ConfirmBoxProps extends DefaultProps { +export interface ConfirmBoxProps extends DefaultProps, TextContentProps { text: string; + width: number; + loc: ElementLocator; + selFont?: string; + selFill?: CanvasStyle; + pad?: number; yesText?: string; noText?: string; - winskin?: string; + winskin?: ImageIds; + defaultYes?: boolean; + color?: CanvasStyle; + border?: CanvasStyle; } -export interface ConfirmBoxEmits { - onYes: () => void; - onNo: () => void; -} +export type ConfirmBoxEmits = { + yes: () => void; + no: () => void; +}; -export const ConfirmBox = defineComponent(() => { - return () => ; -}); +const confirmBoxProps = { + props: [ + 'text', + 'width', + 'loc', + 'selFont', + 'selFill', + 'pad', + 'yesText', + 'noText', + 'winskin', + 'defaultYes', + 'color', + 'border' + ], + emits: ['no', 'yes'] +} satisfies SetupComponentOptions< + ConfirmBoxProps, + ConfirmBoxEmits, + keyof ConfirmBoxEmits +>; + +export const ConfirmBox = defineComponent< + ConfirmBoxProps, + ConfirmBoxEmits, + keyof ConfirmBoxEmits +>((props, { emit, attrs }) => { + const content = ref(); + const height = ref(200); + const selected = ref(props.defaultYes ? true : false); + const yesSize = ref<[number, number]>([0, 0]); + const noSize = ref<[number, number]>([0, 0]); + + const loc = computed(() => { + const [x = 0, y = 0, , , ax = 0, ay = 0] = props.loc; + return [x, y, props.width, height.value, ax, ay]; + }); + const yesText = computed(() => props.yesText ?? '确认'); + const noText = computed(() => props.noText ?? '取消'); + const pad = computed(() => props.pad ?? 32); + const yesLoc = computed(() => { + const y = height.value - pad.value; + return [props.width / 3, y, void 0, void 0, 0.5, 1]; + }); + const noLoc = computed(() => { + const y = height.value - pad.value; + return [(props.width / 3) * 2, y, void 0, void 0, 0.5, 1]; + }); + const contentLoc = computed(() => { + const width = props.width - pad.value * 2; + return [props.width / 2, pad.value, width, 0, 0.5, 0]; + }); + const selectLoc = computed(() => { + if (selected.value) { + const [x = 0, y = 0] = yesLoc.value; + const [width, height] = yesSize.value; + return [x, y + 4, width + 8, height + 8, 0.5, 1]; + } else { + const [x = 0, y = 0] = noLoc.value; + const [width, height] = noSize.value; + return [x, y + 4, width + 8, height + 8, 0.5, 1]; + } + }); + + const onUpdateHeight = (textHeight: number) => { + height.value = textHeight + pad.value * 4; + }; + + const setYes = (_: string, width: number, height: number) => { + yesSize.value = [width, height]; + }; + + const setNo = (_: string, width: number, height: number) => { + noSize.value = [width, height]; + }; + + const [key] = useKey(); + key.realize('confirm', () => { + if (selected.value) emit('yes'); + else emit('no'); + }); + key.realize('moveLeft', () => void (selected.value = true)); + key.realize('moveRight', () => void (selected.value = false)); + + return () => ( + + + + + emit('yes')} + onEnter={() => (selected.value = true)} + onSetText={setYes} + /> + emit('no')} + onEnter={() => (selected.value = false)} + onSetText={setNo} + /> + + ); +}, confirmBoxProps); diff --git a/src/module/render/components/misc.tsx b/src/module/render/components/misc.tsx index ea5d8ab..9be1f0b 100644 --- a/src/module/render/components/misc.tsx +++ b/src/module/render/components/misc.tsx @@ -212,7 +212,7 @@ export const ScrollText = defineComponent< const scroll = ref(); const speed = ref(props.speed); - const eleHeight = computed(() => props.loc[3] ?? props.height ?? 200); + const eleHeight = computed(() => props.loc[3] ?? props.width); const pad = computed(() => props.pad ?? 16); let lastFixedTime = Date.now(); @@ -370,6 +370,15 @@ const backgroundProps = { props: ['loc', 'winskin', 'color', 'border'] } satisfies SetupComponentOptions; +/** + * 背景组件,与 Selection 类似,不过绘制的是背景,而不是选择光标,参数参考 {@link BackgroundProps},用例如下: + * ```tsx + * // 使用 winskin2.png 作为背景 + * + * // 使用指定填充和边框颜色作为背景 + * + * ``` + */ export const Background = defineComponent(props => { const isWinskin = computed(() => !!props.winskin); const fixedLoc = computed(() => { diff --git a/src/module/render/components/textbox.tsx b/src/module/render/components/textbox.tsx index 6f8abd4..ce5cb5b 100644 --- a/src/module/render/components/textbox.tsx +++ b/src/module/render/components/textbox.tsx @@ -3,6 +3,7 @@ import { computed, defineComponent, nextTick, + onMounted, onUnmounted, ref, shallowReactive, @@ -46,6 +47,7 @@ export interface TextContentProps export type TextContentEmits = { typeEnd: () => void; typeStart: () => void; + updateHeight: (height: number) => void; }; export interface TextContentExpose { @@ -89,7 +91,7 @@ const textContentOptions = { 'width', 'autoHeight' ], - emits: ['typeEnd', 'typeStart'] + emits: ['typeEnd', 'typeStart', 'updateHeight'] } satisfies SetupComponentOptions< TextContentProps, TextContentEmits, @@ -144,10 +146,12 @@ export const TextContent = defineComponent< }; const updateLoc = () => { + const height = getHeight(); if (props.autoHeight) { const [x = 0, y = 0, width = 200, , ax = 0, ay = 0] = loc.value; - loc.value = [x, y, width, getHeight(), ax, ay]; + loc.value = [x, y, width, height, ax, ay]; } + emit('updateHeight', height); }; expose({ retype, showAll, getHeight }); @@ -198,6 +202,8 @@ export const TextContent = defineComponent< emit('typeEnd'); }); + onMounted(retype); + return () => { return ( { /** 渲染信息 */ private renderObject: ITextContentRenderObject = { lineHeights: [], - data: [] + data: [], + lineWidths: [] }; /** 渲染信息 */ private renderData: TyperRenderable[] = []; @@ -239,7 +245,7 @@ export class TextContentTyper extends EventEmitter { getHeight() { const heights = this.renderObject.lineHeights; const lines = heights.reduce((prev, curr) => prev + curr, 0); - return lines + this.config.lineHeight * heights.length; + return lines + this.config.lineHeight * heights.length + SAFE_PAD * 2; } /** @@ -275,7 +281,7 @@ export class TextContentTyper extends EventEmitter { this.typing = false; this.dataLine = 0; this.x = 0; - this.y = 0; + this.y = SAFE_PAD; } /** @@ -288,6 +294,19 @@ export class TextContentTyper extends EventEmitter { this.renderObject = this.parser.parse(text, this.config.width); } + private getDataX(line: number) { + const width = this.renderObject.lineWidths[line]; + if (isNil(width)) return this.x; + switch (this.config.textAlign) { + case TextAlign.Left: + return this.x; + case TextAlign.Center: + return this.x + (this.config.width - width) / 2; + case TextAlign.End: + return this.x + this.config.width - width; + } + } + private createTyperData(index: number, line: number) { const renderable = this.renderObject.data[index]; if (!renderable) return false; @@ -303,7 +322,7 @@ export class TextContentTyper extends EventEmitter { const data: TyperTextRenderable = { type: TextContentType.Text, - x: this.x, + x: this.getDataX(line), y: this.y, text: renderable.text.slice(start, end), font: renderable.font, @@ -331,7 +350,7 @@ export class TextContentTyper extends EventEmitter { } const data: TyperIconRenderable = { type: TextContentType.Icon, - x: this.x, + x: this.getDataX(line), y: this.y, width: iconWidth, height: iconWidth / aspect, @@ -537,6 +556,8 @@ export class TextContentParser { private lineHeight: number = 0; /** 每一行的行高 */ private lineHeights: number[] = []; + /** 每一行的宽度 */ + private lineWidths: number[] = []; /** 当前这一行已经有多长 */ private lineWidth: number = 0; /** 这一行未计算部分的起始位置索引 */ @@ -809,6 +830,7 @@ export class TextContentParser { this.nowRenderable = -1; this.lineHeight = 0; this.lineHeights = []; + this.lineWidths = []; this.lineWidth = 0; this.lineStart = 0; this.guessGain = 1; @@ -967,6 +989,7 @@ export class TextContentParser { const index = this.bsLineWidth(maxWidth, this.nowRenderable); data.splitLines.push(this.wordBreak[index]); this.lineHeights.push(this.lineHeight); + this.lineWidths.push(this.lineWidth); this.bsStart = index; const text = data.text.slice( this.wordBreak[index] + 1, @@ -990,10 +1013,11 @@ export class TextContentParser { } } - private bsLineWidth(width: number, index: number) { + private bsLineWidth(maxWidth: number, index: number) { let start = this.bsStart; let end = this.bsEnd; let height = 0; + let width = 0; const data = this.renderable[index]; const { wordBreak } = data; @@ -1005,6 +1029,7 @@ export class TextContentParser { if (height > this.lineHeight) { this.lineHeight = height; } + this.lineWidth = width; return start; } const text = data.text.slice( @@ -1012,10 +1037,12 @@ export class TextContentParser { wordBreak[mid] + 1 ); const metrics = ctx.measureText(text); + width = metrics.width; height = this.getHeight(metrics); - if (metrics.width > width) { + if (width > maxWidth) { end = mid; - } else if (metrics.width === width) { + } else if (width === maxWidth) { + this.lineWidth = width; if (height > this.lineHeight) { this.lineHeight = height; } @@ -1064,6 +1091,7 @@ export class TextContentParser { const index = this.bsLineWidth(maxWidth, pointer); data.splitLines.push(this.wordBreak[index]); this.lineHeights.push(this.lineHeight); + this.lineWidths.push(this.lineWidth); this.bsStart = index; const text = data.text.slice(this.wordBreak[index] + 1); if (!isLast && text.length < guess / 4) { @@ -1089,9 +1117,9 @@ export class TextContentParser { let iconWidth = 0; if (aspect < 1) { // 这时候应该把高度限定在当前字体大小 - iconWidth = width * (this.status.fontSize / height); + iconWidth = width * (data.fontSize / height); } else { - iconWidth = this.status.fontSize; + iconWidth = data.fontSize; } this.lineWidth += iconWidth; const iconHeight = iconWidth / aspect; @@ -1109,13 +1137,69 @@ export class TextContentParser { } } + private checkLastSize() { + const last = this.renderable.at(-1); + if (!last) return; + const index = this.lastBreakIndex; + const text = last.text.slice(this.wordBreak[index] + 1); + const ctx = this.testCanvas.ctx; + ctx.font = last.font; + const metrics = ctx.measureText(text); + this.lineWidth = metrics.width; + const height = this.getHeight(metrics); + if (height > this.lineHeight) { + this.lineHeight = height; + } + } + + private checkNoneBreakSize() { + const ctx = this.testCanvas.ctx; + this.renderable.forEach(data => { + switch (data.type) { + case TextContentType.Text: { + ctx.font = data.font; + const metrics = ctx.measureText(data.text); + this.lineWidth += metrics.width; + const height = this.getHeight(metrics); + if (height > this.lineHeight) this.lineHeight = height; + break; + } + case TextContentType.Icon: { + const renderable = texture.getRenderable(data.icon!); + if (!renderable) return false; + const [, , width, height] = renderable.render[0]; + const aspect = width / height; + let iconWidth = 0; + if (aspect < 1) { + // 这时候应该把高度限定在当前字体大小 + iconWidth = width * (data.fontSize / height); + } else { + iconWidth = data.fontSize; + } + this.lineWidth += iconWidth; + const iconHeight = iconWidth / aspect; + if (iconHeight > this.lineHeight) { + this.lineHeight = iconHeight; + } + } + } + }); + this.lineHeights.push(this.lineHeight); + this.lineWidths.push(this.lineWidth); + } + /** * 对解析出的文字分词并分行 * @param width 文字的宽度,到达这么宽之后换行 */ private splitLines(width: number): ITextContentRenderObject { if (this.wordBreakRule === WordBreak.None) { - return { lineHeights: [0], data: this.renderable }; + this.checkNoneBreakSize(); + return { + lineHeights: this.lineHeights, + data: this.renderable, + lineWidths: this.lineWidths + }; } this.nowRenderable = -1; @@ -1182,11 +1266,14 @@ export class TextContentParser { this.checkRestLine(width, guess, i); } + this.checkLastSize(); this.lineHeights.push(this.lineHeight); + this.lineWidths.push(this.lineWidth); return { lineHeights: this.lineHeights, - data: this.renderable + data: this.renderable, + lineWidths: this.lineWidths }; } }