feat: ConfirmBox 组件

This commit is contained in:
unanmed 2025-02-27 22:33:16 +08:00
parent e232a469c8
commit 0b2db0270c
6 changed files with 293 additions and 33 deletions

View File

@ -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<ETextEvent> {
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<ETextEvent> {
_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<ETextEvent> {
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<ETextEvent> {
setStyle(fill?: CanvasStyle, stroke?: CanvasStyle) {
this.fillStyle = fill;
this.strokeStyle = stroke;
this.update();
}
/**
@ -102,7 +109,11 @@ export class Text extends RenderItem<ETextEvent> {
* @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<ETextEvent> {
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(

View File

@ -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' : ''}></${name}>\n`;
return `${' '.repeat(deep * space)}<${name} id="${item.id}" uid="${item.uid}" type="${item.type}"${item.hidden ? ' hidden' : ''}></${name}>\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)}</${name}>\n`
);

View File

@ -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 () => <container></container>;
});
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<TextContentExpose>();
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<ElementLocator>(() => {
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<ElementLocator>(() => {
const y = height.value - pad.value;
return [props.width / 3, y, void 0, void 0, 0.5, 1];
});
const noLoc = computed<ElementLocator>(() => {
const y = height.value - pad.value;
return [(props.width / 3) * 2, y, void 0, void 0, 0.5, 1];
});
const contentLoc = computed<ElementLocator>(() => {
const width = props.width - pad.value * 2;
return [props.width / 2, pad.value, width, 0, 0.5, 0];
});
const selectLoc = computed<ElementLocator>(() => {
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 () => (
<container loc={loc.value}>
<Background
loc={[0, 0, props.width, height.value]}
winskin={props.winskin}
color={props.color}
border={props.border}
zIndex={0}
/>
<TextContent
{...attrs}
ref={content}
loc={contentLoc.value}
text={props.text}
width={props.width - pad.value * 2}
zIndex={5}
textAlign={TextAlign.Center}
autoHeight
onUpdateHeight={onUpdateHeight}
/>
<Selection
loc={selectLoc.value}
winskin={props.winskin}
color={props.color}
border={props.border}
noevent
zIndex={10}
/>
<text
loc={yesLoc.value}
text={yesText.value}
fillStyle={props.selFill}
font={props.selFont}
cursor="pointer"
zIndex={15}
onClick={() => emit('yes')}
onEnter={() => (selected.value = true)}
onSetText={setYes}
/>
<text
loc={noLoc.value}
text={noText.value}
fillStyle={props.selFill}
font={props.selFont}
cursor="pointer"
zIndex={15}
onClick={() => emit('no')}
onEnter={() => (selected.value = false)}
onSetText={setNo}
/>
</container>
);
}, confirmBoxProps);

View File

@ -212,7 +212,7 @@ export const ScrollText = defineComponent<
const scroll = ref<ScrollExpose>();
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<BackgroundProps>;
/**
* Selection {@link BackgroundProps}
* ```tsx
* // 使用 winskin2.png 作为背景
* <Background loc={[8, 8, 160, 160]} winskin="winskin2.png" />
* // 使用指定填充和边框颜色作为背景
* <Background loc={[8, 8, 160, 160]} color="#333" border="gold" />
* ```
*/
export const Background = defineComponent<BackgroundProps>(props => {
const isWinskin = computed(() => !!props.winskin);
const fixedLoc = computed<ElementLocator>(() => {

View File

@ -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<TextContentExpose>({ retype, showAll, getHeight });
@ -198,6 +202,8 @@ export const TextContent = defineComponent<
emit('typeEnd');
});
onMounted(retype);
return () => {
return (
<sprite

View File

@ -9,6 +9,9 @@ import {
import EventEmitter from 'eventemitter3';
import { isNil } from 'lodash-es';
/** 文字的安全填充,会填充在文字的上侧和下侧,防止削顶和削底 */
const SAFE_PAD = 1;
export const enum WordBreak {
/** 不换行 */
None,
@ -110,6 +113,8 @@ export interface ITextContentRenderable {
export interface ITextContentRenderObject {
/** 每一行的高度 */
lineHeights: number[];
/** 每一行的宽度 */
lineWidths: number[];
/** 渲染数据 */
data: ITextContentRenderable[];
}
@ -173,7 +178,8 @@ export class TextContentTyper extends EventEmitter<TextContentTyperEvent> {
/** 渲染信息 */
private renderObject: ITextContentRenderObject = {
lineHeights: [],
data: []
data: [],
lineWidths: []
};
/** 渲染信息 */
private renderData: TyperRenderable[] = [];
@ -239,7 +245,7 @@ export class TextContentTyper extends EventEmitter<TextContentTyperEvent> {
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<TextContentTyperEvent> {
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<TextContentTyperEvent> {
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<TextContentTyperEvent> {
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<TextContentTyperEvent> {
}
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
};
}
}