吾爱破解 - 52pojie.cn

 找回密码
 注册[Register]

QQ登录

只需一步,快速开始

查看: 431|回复: 3
收起左侧

[学习记录] Canvas 文本编辑器

[复制链接]
wonder2018 发表于 2024-11-26 15:28
本帖最后由 wonder2018 于 2024-11-26 15:34 编辑

Canvas 实现的纯文本编辑器

这是一个基于使用 canvas 实现的简单文本编辑器,具有以下特性。

  1. 支持中英文输入。
  2. 能够正常显示中文输入法的拼音提示。
  3. 能够使用鼠跨行标选中一些文本并高亮。
  4. 能够显示文本插入点光标。
  5. 能够在行尾自动换行

关键技术

  1. 主体框架
    示例将字符串拆成单个文字进行存储,这样方便为每个字符设置不同的属性。渲染时将画布划分成网格,每个文字根据索引填入对应的网格中,使用帧动画来循环检查每个字符是否需要重新渲染,只重绘发送变化的部分。

  2. 实现中文输入的方法
    canvas 本身无法唤起中文输入法,也不支持 CompositionEvent ,因此在页面中嵌入一个 textarea 来完成事件监听部分。通过 pointer-events 使它不再处理鼠标事件来让 canvas 能够正常获取鼠标位置并处理后续文本选择操作。

  3. 实现中文输入法拼音提示的方法
    使用一组 CompositionEvent 来获取拼音提示的部分。需要注意的是,一些网络文章推荐使用 change 事件与 CompositionEvent 结合使用。但这样会造成英文模式下无法捕获每个字母的输入事件。因此本示例使用 input 事件代替 change。虽然这样会带来许多事件重复,但基于 js 单线程运行的特性,可以将每次输入的文本缓存起来,触发事件时如果发现文本没变就直接返回。以此可以减少重复事件带来的影响。

    document.body.addEventListener("click", () => textarea.focus());
    textarea.addEventListener("input", (e) => resetText(e.target.value));
    textarea.addEventListener("compositionstart", (e) => resetText(e.target.value));
    textarea.addEventListener("compositionupdate", (e) => resetText(e.target.value));
    textarea.addEventListener("compositionend", (e) => resetText(e.target.value));
    /** @Param {string} str*/
    const charSeq = [];
    let currentText = "";
    function resetText(str) {
        if (str == currentText) return;
        currentText = str;
        charSeq.length = str.length;
        startChar = null;
        endChar = null;
        str.split("").forEach((i, ind) => {
            const charObj = charSeq[ind] || { char: i, changed: true, selected: false };
            if (charObj.char == i) return (charSeq[ind] = charObj);
            charObj.char = i;
            charObj.changed = true;
        });
    }

一些缺陷

  1. 由于主体渲染逻辑的设计问题,目前暂未实现主动换行的逻辑。并且也未为西文字符设计布局方式。所有字符均按照中文字符进行排列。
  2. 暂未实现在文本中间插入字符和替换字符的功能。后续可以考虑使用 H5 的 selection api 和 range api 实现将 textarea 的光标以及选区和 canvas 同步。
  3. 暂未实现横向滚动条和纵向滚动条

完整代码

在桌面新建 txt 文件,将代码粘贴进去,保存为 utf8 格式文本,然后将扩展名改为 html 即可在浏览器中使用。

<!DOCTYPE html>
<html lang="zh_CN">
    <head>
        <meta charset="UTF-8" />
        <meta name="viewport" content="width=device-width, initial-scale=1.0" />
        <meta name="author" content="wonder2018" />
        <title>canvas editor demo</title>
        <style>
            html,
            body {
                width: 100%;
                height: 100%;
                padding: 0;
                margin: 0;
            }
            body {
                position: relative;
            }
            .editor {
                position: absolute;
                margin: auto;
                left: 0;
                bottom: 0;
                right: 0;
                top: 0;
                padding: 15px;
                border: 1px solid #000;
                border-radius: 8px;
                overflow: hidden;
            }
            .editor > canvas,
            .editor > textarea {
                display: block;
                width: 100%;
                height: 100%;
                overflow: hidden;
                position: absolute;
                left: 0;
                top: 0;
                bottom: 0;
                right: 0;
                margin: auto;
                cursor: text;
            }
            .editor > textarea {
                opacity: 0;
                pointer-events: none;
                user-select: none;
            }
        </style>
    </head>
    <body>
        <div class="editor">
            <canvas></canvas>
            <textarea></textarea>
        </div>
    </body>
    <script>
        const wrap = document.querySelector("div.editor");
        const textarea = document.querySelector("textarea");
        const cvs = document.querySelector("canvas");
        const ctx = cvs.getContext("2d");
        const width = 800;
        const height = 600;
        const bgc = "#fff";
        const color = "#000";
        const selectedBgc = "#000";
        const selectedColor = "#fff";
        const fontSize = 16;
        const lineHeight = 20;
        const charPadding = [Math.floor((lineHeight - fontSize) / 2), 0];
        const charWidth = fontSize + charPadding[1] * 2;
        const cursorPadding = [Math.floor((lineHeight - fontSize) / 2), 2];
        const cursorWidth = 2;
        const cursorHeight = lineHeight - cursorPadding[0] * 2;
        const charSeq = [];
        let currentText = "";
        let lastTextLength = charSeq.length;
        let isCursorShow = false;
        let isPrintCursor = true;
        let isSelecting = false;
        let startChar = null;
        let endChar = null;

        initCvs();
        initEvent();
        startRender();
        setInterval(() => (isPrintCursor = !isPrintCursor), 500);

        function initCvs() {
            cvs.width = width;
            cvs.height = height;
            cvs.style.width = `${width}px`;
            cvs.style.height = `${height}px`;
            wrap.style.width = `${width}px`;
            wrap.style.height = `${height}px`;
            textarea.style.width = `${width}px`;
            textarea.style.height = `${height}px`;
            textarea.style.fontSize = `${fontSize}px`;
            textarea.focus();
        }

        function initEvent() {
            document.body.addEventListener("click", () => textarea.focus());
            textarea.addEventListener("input", (e) => resetText(e.target.value));
            textarea.addEventListener("compositionstart", (e) => resetText(e.target.value));
            textarea.addEventListener("compositionupdate", (e) => resetText(e.target.value));
            textarea.addEventListener("compositionend", (e) => resetText(e.target.value));

            cvs.addEventListener("mousedown", (e) => startSelect(e));
            cvs.addEventListener("mousemove", (e) => endSelect(e, false));
            cvs.addEventListener("mouseup", (e) => endSelect(e, true));
            cvs.addEventListener("mouseleave", (e) => endSelect(e, true));
        }

        /** @param {string} str*/
        function resetText(str) {
            if (str == currentText) return;
            currentText = str;
            charSeq.length = str.length;
            startChar = null;
            endChar = null;
            str.split("").forEach((i, ind) => {
                const charObj = charSeq[ind] || { char: i, changed: true, selected: false };
                if (charObj.char == i) return (charSeq[ind] = charObj);
                charObj.char = i;
                charObj.changed = true;
            });
        }

        /** @param {MouseEvent} e*/
        function startSelect(e) {
            e.stopPropagation();
            e.preventDefault();
            isSelecting = true;
            startChar = pointerCharInd(e.offsetX, e.offsetY);
            endChar = null;
        }
        /** @param {MouseEvent} e*/
        function endSelect(e, stop) {
            if (!isSelecting) return;
            e.stopPropagation();
            e.preventDefault();
            isSelecting = !stop;
            endChar = pointerCharInd(e.offsetX, e.offsetY);
            console.log({ startChar, endChar });
        }

        function pointerCharInd(x, y) {
            const row = Math.floor(Math.min(Math.max(y, 0), height) / lineHeight);
            const col = Math.floor(Math.min(Math.max(x, 0), width) / charWidth);
            const letterCount = letterCountPerLine();
            return row * letterCount + col;
        }

        function startRender() {
            renderFrame();
            return requestAnimationFrame(startRender);
        }

        function renderFrame() {
            toggleCursor();
            if (!isChanged()) return;
            eraseChar(lastTextLength);
            isCursorShow = false;
            for (let i = 0; i < lastTextLength; i++) {
                printChar(charSeq[i], i);
            }
            lastTextLength = charSeq.length;
            return;
        }

        function toggleCursor() {
            const { row, col } = getPosition(lastTextLength);
            const isSelected = startChar != null && endChar != null && Math.min(startChar, endChar) <= lastTextLength;
            if (!isPrintCursor || isSelected) {
                isCursorShow = false;
                eraseChar(lastTextLength);
                return;
            }
            printCursor(lastTextLength);
        }

        function getPosition(ind) {
            const letterCount = letterCountPerLine();
            const row = Math.floor(ind / letterCount);
            const col = ind % letterCount;
            return { row, col };
        }

        function letterCountPerLine() {
            return Math.floor(width / fontSize) || 1;
        }

        function eraseChar(ind) {
            const { row, col } = getPosition(ind);
            ctx.beginPath();
            ctx.fillStyle = bgc;
            ctx.fillRect(col * charWidth, row * lineHeight, charWidth, lineHeight);
            ctx.closePath();
        }

        function printCursor(ind) {
            if (!isPrintCursor) return;
            const { row, col } = getPosition(ind);
            isCursorShow = true;
            ctx.beginPath();
            ctx.fillStyle = color;
            ctx.fillRect(col * charWidth + cursorPadding[1], row * lineHeight + cursorPadding[0], cursorWidth, cursorHeight);
            ctx.closePath();
        }

        function printChar(charObj, ind) {
            const isSelected = isIndSelected(ind);
            if (charObj && !charObj.changed && charObj.selected == isSelected) return;
            if (!charObj) return eraseChar(ind);
            charObj.changed = false;
            charObj.selected = isSelected;

            const { row, col } = getPosition(ind);

            ctx.beginPath();
            ctx.fillStyle = isSelected ? selectedBgc : bgc;
            ctx.fillRect(col * charWidth, row * lineHeight, charWidth, lineHeight);
            ctx.closePath();

            ctx.beginPath();
            ctx.fillStyle = isSelected ? selectedColor : color;
            ctx.font = `${fontSize}px Microsoft`;
            ctx.textBaseline = "middle";
            ctx.fillText(charObj.char, col * charWidth + charPadding[1], row * lineHeight + charPadding[0] + Math.floor(fontSize / 2));
            ctx.closePath();
        }

        function isIndSelected(ind) {
            const selectMin = Math.min(startChar, endChar);
            const selectMax = Math.max(startChar, endChar);
            return startChar != null && endChar != null && selectMin <= ind && selectMax >= ind;
        }

        function isChanged() {
            if (lastTextLength != charSeq.length) return true;
            if (charSeq.some((i) => i.changed)) return true;
            if (charSeq.some((i, ind) => i.selected != isIndSelected(ind))) return true;
            return false;
        }
    </script>
</html>

发帖前要善用论坛搜索功能,那里可能会有你要找的答案或者已经有人发布过相同内容了,请勿重复发帖。

fanliansuo1 发表于 2024-11-26 16:39
谢谢分享
kangta520 发表于 2024-11-26 20:08
firstrose 发表于 2024-11-28 15:58
可以参考一下博客园里面一个叫赵康的人,他也是用canvas做rtf
您需要登录后才可以回帖 登录 | 注册[Register]

本版积分规则

返回列表

RSS订阅|小黑屋|处罚记录|联系我们|吾爱破解 - LCG - LSG ( 京ICP备16042023号 | 京公网安备 11010502030087号 )

GMT+8, 2025-1-5 06:29

Powered by Discuz!

Copyright © 2001-2020, Tencent Cloud.

快速回复 返回顶部 返回列表