文章目录

一、它演示什么
画板上的图形不是像素,而是被录制的绘制命令 :每画一笔记一条命令,控件重绘(被盖住又露出、改大小)时按序回放 ------ 所以没有 "擦掉某一笔"的接口。示例把每条命令另存进一张固定数组,它才是可撤销的"真相来源":撤销 = 丢掉最后一条 → 清空记录 → 重放剩下的。
鼠标事件挂在画板自己 (mousedown / mousemove / mouseup),按下时运行时自动捕获鼠标,拖出边界也不断笔;直线 / 矩形 / 椭圆在拖动中有橡皮筋预览,松手才提交。
下面是「录制-回放」模型的核心流程:
#mermaid-svg-JWPbXyDer3v7SGvI{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-JWPbXyDer3v7SGvI .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-JWPbXyDer3v7SGvI .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-JWPbXyDer3v7SGvI .error-icon{fill:#552222;}#mermaid-svg-JWPbXyDer3v7SGvI .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-JWPbXyDer3v7SGvI .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-JWPbXyDer3v7SGvI .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-JWPbXyDer3v7SGvI .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-JWPbXyDer3v7SGvI .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-JWPbXyDer3v7SGvI .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-JWPbXyDer3v7SGvI .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-JWPbXyDer3v7SGvI .marker{fill:#333333;stroke:#333333;}#mermaid-svg-JWPbXyDer3v7SGvI .marker.cross{stroke:#333333;}#mermaid-svg-JWPbXyDer3v7SGvI svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-JWPbXyDer3v7SGvI p{margin:0;}#mermaid-svg-JWPbXyDer3v7SGvI .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-JWPbXyDer3v7SGvI .cluster-label text{fill:#333;}#mermaid-svg-JWPbXyDer3v7SGvI .cluster-label span{color:#333;}#mermaid-svg-JWPbXyDer3v7SGvI .cluster-label span p{background-color:transparent;}#mermaid-svg-JWPbXyDer3v7SGvI .label text,#mermaid-svg-JWPbXyDer3v7SGvI span{fill:#333;color:#333;}#mermaid-svg-JWPbXyDer3v7SGvI .node rect,#mermaid-svg-JWPbXyDer3v7SGvI .node circle,#mermaid-svg-JWPbXyDer3v7SGvI .node ellipse,#mermaid-svg-JWPbXyDer3v7SGvI .node polygon,#mermaid-svg-JWPbXyDer3v7SGvI .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-JWPbXyDer3v7SGvI .rough-node .label text,#mermaid-svg-JWPbXyDer3v7SGvI .node .label text,#mermaid-svg-JWPbXyDer3v7SGvI .image-shape .label,#mermaid-svg-JWPbXyDer3v7SGvI .icon-shape .label{text-anchor:middle;}#mermaid-svg-JWPbXyDer3v7SGvI .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-JWPbXyDer3v7SGvI .rough-node .label,#mermaid-svg-JWPbXyDer3v7SGvI .node .label,#mermaid-svg-JWPbXyDer3v7SGvI .image-shape .label,#mermaid-svg-JWPbXyDer3v7SGvI .icon-shape .label{text-align:center;}#mermaid-svg-JWPbXyDer3v7SGvI .node.clickable{cursor:pointer;}#mermaid-svg-JWPbXyDer3v7SGvI .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-JWPbXyDer3v7SGvI .arrowheadPath{fill:#333333;}#mermaid-svg-JWPbXyDer3v7SGvI .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-JWPbXyDer3v7SGvI .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-JWPbXyDer3v7SGvI .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-JWPbXyDer3v7SGvI .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-JWPbXyDer3v7SGvI .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-JWPbXyDer3v7SGvI .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-JWPbXyDer3v7SGvI .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-JWPbXyDer3v7SGvI .cluster text{fill:#333;}#mermaid-svg-JWPbXyDer3v7SGvI .cluster span{color:#333;}#mermaid-svg-JWPbXyDer3v7SGvI div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-JWPbXyDer3v7SGvI .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-JWPbXyDer3v7SGvI rect.text{fill:none;stroke-width:0;}#mermaid-svg-JWPbXyDer3v7SGvI .icon-shape,#mermaid-svg-JWPbXyDer3v7SGvI .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-JWPbXyDer3v7SGvI .icon-shape p,#mermaid-svg-JWPbXyDer3v7SGvI .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-JWPbXyDer3v7SGvI .icon-shape .label rect,#mermaid-svg-JWPbXyDer3v7SGvI .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-JWPbXyDer3v7SGvI .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-JWPbXyDer3v7SGvI .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-JWPbXyDer3v7SGvI :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 是
松手提交
用户按下鼠标
捕获鼠标并记录起点
拖动中
写入临时命令 + 整表重放
(橡皮筋预览)
把命令追加进命令表
(真相来源)
重放整张画布
状态栏更新命令条数
撤销:丢最后一条 → 清空 → 重放
二、怎么用
- 在项目根编译:
build-app.bat drawpad,双击examples\drawpad\drawpad.exe。 - 顶部「工具」下拉选一种(直线 / 矩形 / 椭圆 / 文字 / 自由笔);点「颜色」色块弹系统取色器;「笔宽」数值框调 1...12。
- 在白色画板上按下拖动:直线 / 矩形 / 椭圆拖动中就有预览、松手提交;文字工具单击一次落一个字;自由笔一路划过去逐段提交。
- 「撤销」退掉最后一笔,「重做」放回来,「清空」全部丢弃;状态栏三段分别是当前工具、命令条数、最近一次操作。
- 「导出 PNG」把画板截图存进用户数据目录(文件名带时间戳);底部终端会记下每一步作图 / 撤销 / 导出。
- 命令行:
drawpad.exe(自动找同名.cgui)、drawpad.exe xxx.cgui、--selftest。
三、一屏总览
| 控件 id | 类型 | 干什么 |
|---|---|---|
cbo_tool |
combo |
工具:0 直线 / 1 矩形 / 2 椭圆 / 3 文字 / 4 自由笔 |
cp_ink |
colorpicker |
当前色(value = 0xRRGGBB),点它弹系统取色器 |
edt_pen |
edit(spin) |
笔宽数值框,1...12 |
btn_undo / btn_redo / btn_clear |
button |
撤销 / 重做 / 清空,共用一个回调 |
btn_export |
button |
导出 PNG |
cv_board |
canvas |
画板本身,挂 mousedown / mousemove / mouseup |
term_log |
terminal |
每一步操作记一行 |
sb_main |
statusbar |
三段:当前工具 / 命令条数 / 最近操作 |
四、关键接口
- 画板绘制:
cg_canvas_clear、cg_canvas_set_color、cg_canvas_set_line_width、cg_canvas_line、cg_canvas_rect、cg_canvas_ellipse、cg_canvas_text - 鼠标坐标:
cg_event_x、cg_event_y(就是画板内的逻辑像素) - 截图与落盘:
cg_widget_screenshot、cg_app_data_dir、cg_path_join - 记录与状态:
cg_term_write、cg_status_set、cg_status_setf
五、录制-回放 vs 像素擦除
| 维度 | 录制-回放(本示例) | 像素擦除(位图) | 结论 |
|---|---|---|---|
| 撤销粒度 | 按「命令」撤销,可精确回退到任意一笔 | 只能整块擦除像素,无法还原被覆盖的笔画 | 录制-回放更精细,支持逐笔撤销 |
| 内存占用 | 只存命令参数(类型 + 坐标 + 颜色 + 笔宽),固定数组上限 kMaxOps = 4096 |
每帧都要存整张位图,画布越大内存越高 | 录制-回放更省内存,且占用可预估 |
| 缩放质量 | 重放时按当前尺寸重新绘制,任意缩放都清晰 | 位图缩放会模糊 / 锯齿,需额外重采样 | 录制-回放缩放无损,质量更高 |
| 实现复杂度 | 需维护命令表 + 重放逻辑,预览要「临时命令 + 整表重放」 | 直接改像素,实现直观但撤销 / 缩放难做 | 录制-回放略复杂,但换来前三个维度的收益 |
两种方案的取舍可以这样看:
#mermaid-svg-Qj6aHeubsDIzEvCC{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-Qj6aHeubsDIzEvCC .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-Qj6aHeubsDIzEvCC .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-Qj6aHeubsDIzEvCC .error-icon{fill:#552222;}#mermaid-svg-Qj6aHeubsDIzEvCC .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-Qj6aHeubsDIzEvCC .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-Qj6aHeubsDIzEvCC .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-Qj6aHeubsDIzEvCC .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-Qj6aHeubsDIzEvCC .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-Qj6aHeubsDIzEvCC .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-Qj6aHeubsDIzEvCC .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-Qj6aHeubsDIzEvCC .marker{fill:#333333;stroke:#333333;}#mermaid-svg-Qj6aHeubsDIzEvCC .marker.cross{stroke:#333333;}#mermaid-svg-Qj6aHeubsDIzEvCC svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-Qj6aHeubsDIzEvCC p{margin:0;}#mermaid-svg-Qj6aHeubsDIzEvCC .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-Qj6aHeubsDIzEvCC .cluster-label text{fill:#333;}#mermaid-svg-Qj6aHeubsDIzEvCC .cluster-label span{color:#333;}#mermaid-svg-Qj6aHeubsDIzEvCC .cluster-label span p{background-color:transparent;}#mermaid-svg-Qj6aHeubsDIzEvCC .label text,#mermaid-svg-Qj6aHeubsDIzEvCC span{fill:#333;color:#333;}#mermaid-svg-Qj6aHeubsDIzEvCC .node rect,#mermaid-svg-Qj6aHeubsDIzEvCC .node circle,#mermaid-svg-Qj6aHeubsDIzEvCC .node ellipse,#mermaid-svg-Qj6aHeubsDIzEvCC .node polygon,#mermaid-svg-Qj6aHeubsDIzEvCC .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-Qj6aHeubsDIzEvCC .rough-node .label text,#mermaid-svg-Qj6aHeubsDIzEvCC .node .label text,#mermaid-svg-Qj6aHeubsDIzEvCC .image-shape .label,#mermaid-svg-Qj6aHeubsDIzEvCC .icon-shape .label{text-anchor:middle;}#mermaid-svg-Qj6aHeubsDIzEvCC .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-Qj6aHeubsDIzEvCC .rough-node .label,#mermaid-svg-Qj6aHeubsDIzEvCC .node .label,#mermaid-svg-Qj6aHeubsDIzEvCC .image-shape .label,#mermaid-svg-Qj6aHeubsDIzEvCC .icon-shape .label{text-align:center;}#mermaid-svg-Qj6aHeubsDIzEvCC .node.clickable{cursor:pointer;}#mermaid-svg-Qj6aHeubsDIzEvCC .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-Qj6aHeubsDIzEvCC .arrowheadPath{fill:#333333;}#mermaid-svg-Qj6aHeubsDIzEvCC .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-Qj6aHeubsDIzEvCC .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-Qj6aHeubsDIzEvCC .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-Qj6aHeubsDIzEvCC .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-Qj6aHeubsDIzEvCC .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-Qj6aHeubsDIzEvCC .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-Qj6aHeubsDIzEvCC .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-Qj6aHeubsDIzEvCC .cluster text{fill:#333;}#mermaid-svg-Qj6aHeubsDIzEvCC .cluster span{color:#333;}#mermaid-svg-Qj6aHeubsDIzEvCC div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-Qj6aHeubsDIzEvCC .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-Qj6aHeubsDIzEvCC rect.text{fill:none;stroke-width:0;}#mermaid-svg-Qj6aHeubsDIzEvCC .icon-shape,#mermaid-svg-Qj6aHeubsDIzEvCC .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-Qj6aHeubsDIzEvCC .icon-shape p,#mermaid-svg-Qj6aHeubsDIzEvCC .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-Qj6aHeubsDIzEvCC .icon-shape .label rect,#mermaid-svg-Qj6aHeubsDIzEvCC .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-Qj6aHeubsDIzEvCC .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-Qj6aHeubsDIzEvCC .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-Qj6aHeubsDIzEvCC :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 需要逐笔撤销
只整块擦除即可
画板方案选型
撤销粒度要求
录制-回放
(本示例)
像素擦除
(位图)
优点:逐笔撤销 / 省内存 / 缩放无损
代价:命令表 + 重放逻辑较复杂
优点:实现直观
代价:撤销难 / 内存高 / 缩放模糊
结论:追求精细撤销与质量 → 选录制-回放
结论:简单场景够用 → 选像素擦除
六、容易踩的坑
- 鼠标事件必须挂在画板这个子控件上 ,不能挂窗口级
app.events------ 按下 / 移动 / 抬起落在画板上时消息只送给画板,父窗口收不到(早先挂窗口的写法在真机上"按下拖动毫无反应")。 - 画板事件里的坐标就是画板内 像素,不用再减
cg_widget_x/cg_widget_y。 - 画板的绘制是"录制 → 回放"模型,没有"画一笔但不作数"的接口 ,所以拖动预览只能"把临时命令写进命令表空槽 + 整表重放"(见
preview_op)。 - 命令表是固定数组(上限
kMaxOps = 4096):满了只报一次提示并拒绝起笔;自由笔贴着上一个点 2px 以内的移动不记,免得几秒见底。 - 画板重绘走双缓冲,所以"每步重放整张画布"的预览不会闪。
七、常见问题排查
Q1:画板无响应,按下拖动毫无反应?
- 原因 :鼠标事件挂错了对象,挂到了窗口级
app.events而不是画板子控件cv_board上。 - 解决 :把
mousedown/mousemove/mouseup全部挂到cv_board上;按下时运行时自动捕获鼠标,拖出边界也不断笔。
Q2:拖动预览闪烁?
- 原因:画板重绘没有走双缓冲,或预览时直接改像素导致整块重绘。
- 解决:确认画板重绘走双缓冲;预览用「临时命令写进命令表空槽 + 整表重放」的方式,避免逐像素直接改。
Q3:命令表满了,画不进去?
- 原因 :命令表是固定数组,上限
kMaxOps = 4096,画满后拒绝起笔。 - 解决:先「清空」或「撤销」释放空间;自由笔贴着上一个点 2px 以内的移动不记,可有效延缓命令表见底。
Q4:导出 PNG 失败?
- 原因:用户数据目录不可写,或文件名带非法字符。
- 解决 :确认
cg_app_data_dir返回的目录存在且可写;导出文件名带时间戳,若仍失败请检查磁盘空间与目录权限。
Q5:文字工具单击落字位置偏移?
- 原因:文字工具单击落字时,坐标换算用了窗口坐标而不是画板内坐标,或没考虑画板在窗口内的偏移。
- 解决 :确认落字坐标直接用
cg_event_x/cg_event_y(就是画板内逻辑像素),不要再减cg_widget_x/cg_widget_y。
问题速查表
| 问题 | 现象 | 原因 | 解决方案 |
|---|---|---|---|
| Q1 画板无响应 | 按下拖动毫无反应 | 鼠标事件挂到了窗口级 app.events,而非画板子控件 cv_board |
把 mousedown / mousemove / mouseup 全部挂到 cv_board 上 |
| Q2 预览闪烁 | 拖动中画布闪烁 | 画板重绘未走双缓冲,或预览时直接改像素 | 重绘走双缓冲;预览用「临时命令 + 整表重放」 |
| Q3 命令表满 | 画不进去,拒绝起笔 | 命令表固定数组上限 kMaxOps = 4096 已满 |
先「清空」或「撤销」释放空间;自由笔 2px 内移动不记 |
| Q4 导出 PNG 失败 | 导出报错或文件未生成 | 用户数据目录不可写,或文件名带非法字符 | 确认 cg_app_data_dir 目录存在且可写;检查磁盘空间与权限 |
| Q5 文字落字偏移 | 单击落字位置不对 | 坐标用了窗口坐标,或没考虑画板偏移 | 直接用 cg_event_x / cg_event_y,不再减 cg_widget_x / cg_widget_y |