欢迎光临
我们一直在努力

鸿蒙实战:图片编辑器——文字功能完全实现

鸿蒙实战:图片编辑器——文字功能完全实现(添加/选中/拖拽/编辑/删除/撤回/多行排版)

完整源码:ImageEditor

基于 HarmonyOS 5.0+,在涂鸦/马赛克编辑器基础上,从零实现功能完备的文字模块。本文深入讲解独立文字层架构、多行文本精确换行、互斥手势、命中测试、撤回栈等核心难点。

一、功能完整清单

功能模块具体能力实现状态
文字添加 弹窗输入文字,设置颜色、大小,确认后添加到图片中心 ✅
单击选中 点击文字显示白色边框、四个角点、右上角删除按钮 ✅
拖拽移动 按住文字主体可任意拖动,实时更新位置,支持撤回 ✅
双击编辑 双击文字弹出编辑弹窗,预填当前内容和样式 ✅
删除文字 点击选中框右上角圆形按钮直接删除 ✅
实时样式调节 选中文字后,底部工具栏修改颜色/大小立即生效 ✅
多行文本 支持手动换行 \\n 和自动换行,精确行间距 ✅
撤回操作 添加、删除、移动、编辑、样式修改均可撤回 ✅
点击空白取消选中 单击非文字区域清除选中 ✅
切换工具自动取消选中 切换到涂鸦/马赛克/裁剪时清空文字选中 ✅
与涂鸦/马赛克完全隔离 独立文字层,手势互不干扰 ✅

运行效果

文字功能演示.gif

二、架构设计概览

Index (UI层)
├─ 管理三个独立配置 (draw/mosaic/text)
├─ 监听文字选中/双击回调
├─ 控制文字编辑弹窗
└─ 组合手势 (TapGesture + PanGesture,互斥模式)

EditorManager (代理层)
└─ 封装 CanvasManager,提供简洁 API

CanvasManager (核心管理层)
├─ 命令栈(commands + textUndoStack)
├─ 图片渲染 & 裁剪区域管理
├─ 手势分流 (文字/涂鸦/马赛克)
└─ 委托 TextLayerManager

TextLayerManager (独立文字层)
├─ 文字命令列表
├─ 多行文本排版 (splitLines)
├─ 命中测试 (hitTest)
├─ 选中/拖拽/双击/删除逻辑
├─ 绘制文字 + 选中框
└─ 回调通知 (选中变化/移动/删除)

三、核心代码实现

3.1 数据模型

export interface TextInfo {
id: string;
text: string;
color: string;
fontSize: number;
}

// TextCommand.ts
import { TextInfo } from './TextInfo';
export interface TextCommand extends TextInfo {
type: 'text';
x: number;
y: number;
fontFamily: string;
rotation: number; // 预留旋转
scaleX: number; // 预留缩放
scaleY: number;
}

export interface HitTestResult {
cmd: TextCommand;
isDelete: boolean;
}

3.2 文字层核心 (TextLayerManager.ets) — 完整代码

import { TextCommand } from '../model/TextCommand';
import { TextInfo } from '../model/TextInfo';
import { HitTestResult } from '../model/HitTestResult';

interface LineMetrics {
text: string;
width: number;
ascent: number;
descent: number;
y: number;
}

interface SplitLinesResult {
lines: LineMetrics[];
totalWidth: number;
totalHeight: number;
}

interface BoundingBox {
left: number;
top: number;
right: number;
bottom: number;
totalWidth: number;
totalHeight: number;
}

export class TextLayerManager {
public static readonly SELECTION_PADDING_X: number = 8;
public static readonly SELECTION_PADDING_Y: number = 12;
private static readonly LINE_SPACING: number = 8; // px

private commands: TextCommand[] = [];
private selectedId: string | null = null;
private isDragging: boolean = false;
private dragStartX: number = 0;
private dragStartY: number = 0;
private dragStartTextX: number = 0;
private dragStartTextY: number = 0;
private dragStartSnapshot: TextCommand | null = null;
private ctx: CanvasRenderingContext2D;
private currentWrapWidth: number = 0;

public onTextSelected?: (info: TextInfo | null) => void;
public onTextDoubleClick?: (info: TextInfo) => void;
public onTextDelete?: (id: string) => void;
public onTextMoved?: (oldCmd: TextCommand) => void;

constructor(ctx: CanvasRenderingContext2D) {
this.ctx = ctx;
}

public setWrapWidth(width: number): void {
this.currentWrapWidth = width;
}

public setCommands(commands: TextCommand[]): void {
this.commands = commands;
if (this.selectedId !== null && !this.commands.some(c => c.id === this.selectedId)) {
this.selectedId = null;
this.notifySelected();
}
}

public getSelectedInfo(): TextInfo | null {
if (!this.selectedId) return null;
const selected = this.commands.find(c => c.id === this.selectedId);
return selected ? { id: selected.id, text: selected.text, color: selected.color, fontSize: selected.fontSize } : null;
}

public updateTextAttribute(id: string, color?: string, fontSize?: number): void {
const cmd = this.commands.find(c => c.id === id);
if (cmd) {
if (color) cmd.color = color;
if (fontSize) cmd.fontSize = fontSize;
this.notifySelected();
}
}

// ——————– 排版核心 ——————–
private getWrapWidth(): number {
return this.currentWrapWidth > 0 ? this.currentWrapWidth : this.ctx.width;
}

private splitLines(text: string, wrapWidth: number, fontSize: number, fontFamily: string): SplitLinesResult {
this.ctx.save();
this.ctx.font = `${fontSize}px ${fontFamily}`;
const paragraphs = text.split('\\n');
const allLines: LineMetrics[] = [];

for (const para of paragraphs) {
if (para.length === 0) {
allLines.push({ text: '', width: 0, ascent: 0, descent: 0, y: 0 });
continue;
}
const chars = para.split('');
let curLine = '';
let curWidth = 0;
for (const ch of chars) {
const w = this.ctx.measureText(ch).width;
if (curWidth + w <= wrapWidth) {
curLine += ch;
curWidth += w;
} else {
if (curLine.length) {
const m = this.ctx.measureText(curLine);
allLines.push({ text: curLine, width: m.width, ascent: m.actualBoundingBoxAscent, descent: m.actualBoundingBoxDescent, y: 0 });
}
curLine = ch;
curWidth = w;
}
}
if (curLine.length) {
const m = this.ctx.measureText(curLine);
allLines.push({ text: curLine, width: m.width, ascent: m.actualBoundingBoxAscent, descent: m.actualBoundingBoxDescent, y: 0 });
}
}
this.ctx.restore();

let totalWidth = 0;
for (const l of allLines) if (l.width > totalWidth) totalWidth = l.width;

let totalHeight = 0;
let curY = 0;
for (let i = 0; i < allLines.length; i++) {
const l = allLines[i];
const lineHeight = l.ascent + l.descent;
l.y = curY;
if (i === allLines.length – 1) {
totalHeight += lineHeight;
} else {
totalHeight += lineHeight + TextLayerManager.LINE_SPACING;
curY += lineHeight + TextLayerManager.LINE_SPACING;
}
}
return { lines: allLines, totalWidth, totalHeight };
}

private getTextBoundingBox(cmd: TextCommand): BoundingBox {
const wrap = this.getWrapWidth();
const { totalWidth, totalHeight } = this.splitLines(cmd.text, wrap, cmd.fontSize, cmd.fontFamily);
const left = cmd.x – totalWidth / 2;
const top = cmd.y – totalHeight / 2;
return { left, top, right: left + totalWidth, bottom: top + totalHeight, totalWidth, totalHeight };
}

private drawMultilineText(cmd: TextCommand): void {
const wrap = this.getWrapWidth();
const { lines, totalWidth, totalHeight } = this.splitLines(cmd.text, wrap, cmd.fontSize, cmd.fontFamily);
const startX = cmd.x – totalWidth / 2;
const startY = cmd.y – totalHeight / 2;
this.ctx.save();
this.ctx.font = `${cmd.fontSize}px ${cmd.fontFamily}`;
this.ctx.fillStyle = cmd.color;
this.ctx.textAlign = 'left';
this.ctx.textBaseline = 'top';
for (const line of lines) {
const x = startX;
const y = startY + line.y;
this.ctx.fillText(line.text, x, y);
}
this.ctx.restore();
}

// ——————– 命中测试 ——————–
private hitTest(x: number, y: number): HitTestResult | null {
for (let i = this.commands.length – 1; i >= 0; i—) {
const cmd = this.commands[i];
const bbox = this.getTextBoundingBox(cmd);
const left = bbox.left – TextLayerManager.SELECTION_PADDING_X;
const top = bbox.top – TextLayerManager.SELECTION_PADDING_Y;
const right = bbox.right + TextLayerManager.SELECTION_PADDING_X;
const bottom = bbox.bottom + TextLayerManager.SELECTION_PADDING_Y;

// 删除按钮区域 (右上角圆形半径12)
const delX = right;
const delY = top;
const radius = 12;
const dx = x – delX;
const dy = y – delY;
if (dx * dx + dy * dy <= radius * radius) {
return { cmd, isDelete: true };
}
if (x >= left && x <= right && y >= top && y <= bottom) {
return { cmd, isDelete: false };
}
}
return null;
}

// ——————– 交互事件 ——————–
// 单击
public handleStart(x: number, y: number): void {
const hit = this.hitTest(x, y);
if (hit) {
if (hit.isDelete) {
this.onTextDelete?.(hit.cmd.id);
if (this.selectedId === hit.cmd.id) {
this.selectedId = null;
this.notifySelected();
}
return;
}
this.selectedId = hit.cmd.id;
this.notifySelected();
} else {
if (this.selectedId !== null) {
this.selectedId = null;
this.notifySelected();
}
}
}
// 开始拖拽
public startDrag(x: number, y: number): boolean {
const hit = this.hitTest(x, y);
if (hit && !hit.isDelete) {
this.selectedId = hit.cmd.id;
this.isDragging = true;
this.dragStartX = x;
this.dragStartY = y;
this.dragStartTextX = hit.cmd.x;
this.dragStartTextY = hit.cmd.y;
this.dragStartSnapshot = { …hit.cmd };
this.notifySelected();
return true;
}
return false;
}
// 拖拽移动
public handleMove(x: number, y: number): void {
if (!this.isDragging || !this.selectedId) return;
const target = this.commands.find(c => c.id === this.selectedId);
if (target) {
target.x = this.dragStartTextX + (x – this.dragStartX);
target.y = this.dragStartTextY + (y – this.dragStartY);
}
}
// 结束拖拽
public handleEnd(): void {
if (this.isDragging && this.selectedId && this.dragStartSnapshot) {
const target = this.commands.find(c => c.id === this.selectedId);
if (target && (target.x !== this.dragStartSnapshot.x || target.y !== this.dragStartSnapshot.y)) {
this.onTextMoved?.(this.dragStartSnapshot);
}
}
this.isDragging = false;
this.dragStartSnapshot = null;
}

public handleDoubleClick(x: number, y: number): void {
const hit = this.hitTest(x, y);
if (hit && !hit.isDelete) {
const info: TextInfo = {
id: hit.cmd.id,
text: hit.cmd.text,
color: hit.cmd.color,
fontSize: hit.cmd.fontSize
};
this.onTextDoubleClick?.(info);
}
}

// ——————– 绘制 ——————–
public drawAll(): void {
for (const cmd of this.commands) {
this.drawMultilineText(cmd);
}
if (this.selectedId) {
const selected = this.commands.find(c => c.id === this.selectedId);
if (selected) this.drawSelectionBox(selected);
}
}

private drawSelectionBox(cmd: TextCommand): void {
const bbox = this.getTextBoundingBox(cmd);
const left = bbox.left – TextLayerManager.SELECTION_PADDING_X;
const top = bbox.top – TextLayerManager.SELECTION_PADDING_Y;
const right = bbox.right + TextLayerManager.SELECTION_PADDING_X;
const bottom = bbox.bottom + TextLayerManager.SELECTION_PADDING_Y + 4; // 底部补偿

this.ctx.save();
this.ctx.strokeStyle = '#ffffff';
this.ctx.lineWidth = 2;
this.ctx.strokeRect(left, top, right – left, bottom – top);

// 四个角点
const pointSize = 8;
const corners = [[left, top], [right, top], [left, bottom], [right, bottom]];
this.ctx.fillStyle = '#ffffff';
for (const p of corners) {
this.ctx.fillRect(p[0] – pointSize/2, p[1] – pointSize/2, pointSize, pointSize);
}

// 删除按钮
const delX = right;
const delY = top;
const radius = 12;
this.ctx.fillStyle = '#ffffff';
this.ctx.beginPath();
this.ctx.arc(delX, delY, radius, 0, 2 * Math.PI);
this.ctx.fill();
this.ctx.fillStyle = '#ff3b30';
this.ctx.font = '42px sans-serif';
this.ctx.fillText('✕', delX – 4, delY + 4);
this.ctx.restore();
}

// ——————– 选中管理 ——————–
public clearSelection(): void {
if (this.selectedId !== null) {
this.selectedId = null;
this.notifySelected();
}
}

private notifySelected(): void {
this.onTextSelected?.(this.getSelectedInfo());
}
}

3.3 CanvasManager 文字相关部分

import { image } from '@kit.ImageKit';
import { DrawCommand } from '../model/DrawCommand';
import { DrawStrategy } from '../drawing/DrawStrategy';
import { MosaicStrategy } from '../drawing/MosaicStrategy';
import { CanvasInfo, DrawToolType, DrawRect } from '../model/CanvasInfo';
import { promptAction } from '@kit.ArkUI';
import { TextCommand } from '../model/TextCommand';
import { DrawPoint } from '../model/DrawPoint';
import { TextLayerManager } from './TextLayerManager';
import { TextInfo } from '../model/TextInfo';

type CanvasCommand = DrawCommand | TextCommand;

export class CanvasManager {
private ctx: CanvasRenderingContext2D;
private drawStrategy: DrawStrategy;
private mosaicStrategy: MosaicStrategy;
private commands: CanvasCommand[] = [];
private textUndoStack: TextCommand[] = []; // 文字撤回专用栈
private currentCommand: DrawCommand | null = null;
private currentTool: DrawToolType = 'draw';
private currentColor: string = '#ff0000';
private currentSize: number = 8;
private originalImage: image.PixelMap | null = null;
private canvasInfo: CanvasInfo | null = null;
private isClipped: boolean = false;
private textLayerManager: TextLayerManager;

constructor(ctx: CanvasRenderingContext2D) {
this.ctx = ctx;
this.drawStrategy = new DrawStrategy();
this.mosaicStrategy = new MosaicStrategy();
this.mosaicStrategy.init(ctx);
this.textLayerManager = new TextLayerManager(ctx);

this.textLayerManager.onTextDelete = (id: string) => {
this.deleteText(id);
};
this.textLayerManager.onTextMoved = (oldCmd: TextCommand) => {
this.pushTextUndoSnapshot(oldCmd);
};
}

// ================== 文字撤回专用 ==================
private pushTextUndoSnapshot(cmd: TextCommand): void {
const snapshot: TextCommand = {
id: cmd.id,
type: 'text',
text: cmd.text,
x: cmd.x,
y: cmd.y,
fontSize: cmd.fontSize,
fontFamily: cmd.fontFamily,
color: cmd.color,
rotation: cmd.rotation,
scaleX: cmd.scaleX,
scaleY: cmd.scaleY
};
this.textUndoStack.push(snapshot);
}

private syncTextCommands(): void {
const textCmds: TextCommand[] = [];
for (const cmd of this.commands) {
if (cmd.type === 'text') {
textCmds.push(cmd as TextCommand);
}
}
this.textLayerManager.setCommands(textCmds);
}

// ================== 文字操作完整实现 ==================
/** 添加文字(会自动记录到 commands 和 textUndoStack?通常新添加不需要立即入撤回栈,但撤回时可通过整体 commands 回退) */
addText(x: number, y: number, text: string, color: string, fontSize: number): void {
const cmd: TextCommand = {
id: Date.now().toString() + Math.random().toString(),
type: 'text',
text: text,
x: x,
y: y,
fontSize: fontSize,
fontFamily: 'HarmonyOS Sans',
color: color,
rotation: 0,
scaleX: 1,
scaleY: 1
};
this.commands.push(cmd);
// 新添加的文字不主动推入撤回栈(因为撤回时会整体 pop 或者文字栈单独处理)
// 但如果要支持撤销“添加”,应该在 undo 逻辑中处理 commands 的 pop,或者单独推入一个“添加动作”。
// 为简化,添加时不推入 textUndoStack,而是在 undo 中优先 textUndoStack 为空时走 commands pop。
this.syncTextCommands();
this.redrawAll();
}

/** 编辑文字(内容、颜色、大小全部替换) */
updateText(id: string, text: string, color: string, fontSize: number): void {
const index = this.commands.findIndex(c => c.id === id && c.type === 'text');
if (index === –1) return;
const oldCmd = this.commands[index] as TextCommand;
// 保存旧快照
this.pushTextUndoSnapshot(oldCmd);
const newCmd: TextCommand = {
id: oldCmd.id,
type: 'text',
text: text,
x: oldCmd.x,
y: oldCmd.y,
fontSize: fontSize,
fontFamily: oldCmd.fontFamily,
color: color,
rotation: oldCmd.rotation,
scaleX: oldCmd.scaleX,
scaleY: oldCmd.scaleY
};
this.commands[index] = newCmd;
this.syncTextCommands();
this.redrawAll();
}

/** 删除文字 */
deleteText(id: string): void {
const index = this.commands.findIndex(c => c.id === id && c.type === 'text');
if (index === –1) return;
const oldCmd = this.commands[index] as TextCommand;
this.pushTextUndoSnapshot(oldCmd);
this.commands.splice(index, 1);
this.syncTextCommands();
this.redrawAll();
}

/** 仅修改文字属性(颜色或大小) */
updateTextAttribute(id: string, color?: string, fontSize?: number): void {
const index = this.commands.findIndex(c => c.id === id && c.type === 'text');
if (index !== –1) {
const oldCmd = this.commands[index] as TextCommand;
this.pushTextUndoSnapshot(oldCmd);
const newCmd: TextCommand = {
id: oldCmd.id,
type: 'text',
text: oldCmd.text,
x: oldCmd.x,
y: oldCmd.y,
fontSize: (fontSize !== undefined) ? fontSize : oldCmd.fontSize,
fontFamily: oldCmd.fontFamily,
color: (color !== undefined) ? color : oldCmd.color,
rotation: oldCmd.rotation,
scaleX: oldCmd.scaleX,
scaleY: oldCmd.scaleY
};
this.commands[index] = newCmd;
this.syncTextCommands();
this.redrawAll();
}
}

/** 清除当前选中的文字(仅 UI 状态) */
clearTextSelection(): void {
this.textLayerManager.clearSelection();
this.redrawAll();
}

// ================== 撤回(集成文字和绘图) ==================
async undo(): Promise<void> {
// 1. 优先处理文字的独立撤回栈
if (this.textUndoStack.length > 0) {
const snapshot = this.textUndoStack.pop()!;
const index = this.commands.findIndex(c => c.id === snapshot.id && c.type === 'text');
if (index !== –1) {
this.commands[index] = snapshot;
} else {
this.commands.push(snapshot);
}
this.syncTextCommands();
await this.redrawAll();
this.showToast('已撤回');
return;
}
// 2. 涂鸦/马赛克撤回
if (this.commands.length === 0) {
this.showToast('没有可撤回的操作');
return;
}
this.commands.pop();
await this.redrawAll();
this.showToast('已撤回');
}

// ================== 辅助方法 ==================
private showToast(message: string): void {
promptAction.showToast({ message: message });
}

// 重绘全部(含文字层)
async redrawAll(): Promise<void> {
// … 已有重绘逻辑(重新渲染原图 + 绘制 commands + textLayerManager.drawAll)
// 这里省略与文字无关部分,实际代码中保持不变
}

// 其他与文字无关的方法(如 setOriginalImage, renderOriginalImage 等)省略
}

3.4 Index.ets 手势与选中清除

// Index.ets
@Entry
@Component
struct Index {
@State selectedTextInfo: TextInfo | null = null;
private editorManager: EditorManager | null = null;

aboutToAppear() {
this.editorManager = new EditorManager(this.context, this.ctx);
this.editorManager?.setOnTextSelected((info) => {
this.selectedTextInfo = info;
// 同步工具条样式…
});
}

// 清除文字选中的统一方法
private clearTextSelection(): void {
this.editorManager?.clearTextSelection();
this.selectedTextInfo = null;
}

private onToolChange(tool: DrawToolType): void {
this.currentTool = tool;
this.clearTextSelection(); // 任何工具切换都清除选中
if (tool === 'text') this.showTextEditor = true;
}

private async undo(): Promise<void> {
this.clearTextSelection();
await this.editorManager?.undo();
}

private async clear(): Promise<void> {
this.clearTextSelection();
await this.editorManager?.clear();
}

build() {
Canvas(this.ctx)
.gesture(
GestureGroup(GestureMode.Exclusive,
TapGesture({ count: 2 }).onAction((e) => {
if (this.currentTool === 'text') {
const { localX, localY } = e.fingerList[0];
this.editorManager?.handleTextDoubleClick(localX, localY);
}
}),
TapGesture({ count: 1 }).onAction((e) => {
if (this.currentTool === 'text') {
const { localX, localY } = e.fingerList[0];
this.editorManager?.startDraw(localX, localY); // 单击选中
}
}),
PanGesture({ fingers: 1, distance: 5 })
.onActionStart((e) => {
if (this.currentTool === 'text') {
const { localX, localY } = e.fingerList[0];
this.editorManager?.startDragText(localX, localY);
} else {
this.onGestureStart(e);
}
})
.onActionUpdate((e) => {
if (this.currentTool === 'text') {
const { localX, localY } = e.fingerList[0];
this.editorManager?.updateDraw(localX, localY);
} else {
this.onGestureUpdate(e);
}
})
.onActionEnd(() => {
if (this.currentTool === 'text') {
this.editorManager?.endDraw();
} else {
this.onGestureEnd();
}
})
)
)
}
}

四、核心难点解析

4.1 多行文本精确换行与行高计算

  • 使用 ascent + descent 获得实际文字高度,避免字体乘系数误差。
  • 行间距采用固定 px(LINE_SPACING = 8),保证视觉统一。
  • 手动换行符 \\n 分段处理,每段内自动换行。

4.2 互斥手势避免冲突

  • GestureMode.Exclusive 确保单击、双击、拖拽只触发一种。
  • 双击放在最前,系统会延迟约 200ms 判断,双击成功时不触发单击。
  • 拖拽距离阈值 distance:5 防止微小移动触发拖拽。

4.3 撤回双栈独立管理

  • 涂鸦/马赛克使用 commands 数组,直接 pop 重绘。
  • 文字使用独立 textUndoStack,每次修改前深拷贝旧命令入栈,撤回时替换回数组。

4.4 删除按钮圆形命中区域

  • 命中测试中优先计算与右上角圆心的距离,radius=12。
  • 绘制删除按钮时圆心与 (right, top) 对齐。

五、总结

本文完整实现了图片编辑器中的文字模块,涵盖了从数据模型、多行排版、组合手势、单位转换到撤回的方方面面。通过独立文字层设计,保证了与涂鸦/马赛克的完美共存;通过精确的度量计算,实现了高质量的排版和交互。如果觉得有用,请点赞、收藏、转发支持!

赞(0)
未经允许不得转载:171主机测评 » 鸿蒙实战:图片编辑器——文字功能完全实现
分享到: 更多 (0)

评论 抢沙发

  • 昵称 (必填)
  • 邮箱 (必填)
  • 网址