ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

js 获取与设置光标位置:TaoToken 统一 Key 通道下的输入框交互实战

js 获取与设置光标位置:TaoToken 统一 Key 通道下的输入框交互实战 1. 原生 JS 光标操作到底难在哪从 input 到 contenteditable 的完整场景做前端表单交互绕不开光标位置这件事。你可能会觉得不就是selectionStart和selectionEnd两个属性吗我一开始也这么想直到在一个富文本评论框里踩了坑——textarea上跑得好好的代码换到contenteditable的div上直接报undefined。这才意识到光标位置的读写在不同元素类型下走的是两套完全不同的 API。先说清楚这篇文章能帮你解决什么。如果你正在做以下任意一件事这篇内容都直接对口在输入框光标处插入表情、提及、变量占位符做代码编辑器的高亮同步实现撤销重做时恢复光标给 AI 对话输入框做「引用选中文本」功能。这些场景的共同点是——你必须精确知道光标在哪并且能把它放回去。适合谁看有基础 DOM 操作经验的前端能看懂document.getElementById和事件监听就行。不需要你懂富文本编辑器源码但如果你做过textarea的v-model双向绑定理解起来会更快。核心检索词先摆出来js 获取光标位置、js 设置光标位置、selectionStart/selectionEnd、setSelectionRange、Range 与 Selection 对象。这几个词贯穿全文你搜到的多数方案都围绕它们展开。我实测下来最容易混淆的是三类元素的差异第一类input和textarea。它们属于表单控件浏览器原生支持selectionStart、selectionEnd、setSelectionRange()。读写光标就是操作这几个属性简单直接。注意input的type必须是text、search、url、tel、password这类文本类型typenumber或typeemail在部分浏览器上selectionStart会返回null这是很多人第一次踩的坑。第二类contenteditable元素。它没有selectionStart光标信息藏在window.getSelection()返回的Selection对象里。你要通过selection.getRangeAt(0)拿到Range再用range.startOffset、range.endOffset读位置。设置光标则要反过来先创建Range再selection.removeAllRanges()后addRange()。第三类iframe内的文档或 Shadow DOM。光标对象归属于对应的document或shadowRoot不能直接用全局window.getSelection()得从对应上下文取。这三类的差异就是「为什么我的代码在这个输入框能用、换个地方就崩」的根因。下面我会把每一类的读写函数都写成可直接复制的版本并且用 TaoToken 统一 Key 通道来生成边界用例和回归测试——毕竟光标位置的边界情况空输入、全选、末尾、emoji 占两个码元靠手写测试很容易漏让模型帮你列全更省事。2. TaoToken 统一 Key 通道前置准备一个 Key 管多模型方便生成测试用例在写光标函数之前先把这个「辅助工具」配好。为什么光标操作需要它因为边界用例的枚举很烦。比如「光标在 emoji 中间」「选区跨越换行符」「输入框为空时 startOffset 是多少」——这些你让模型一次性列出来比你自己想快得多。而 TaoToken 的价值在于你用一个 Key 就能切换不同模型来交叉验证这些用例不用为每个模型单独申请账号、记多套 Base URL。TaoToken 是什么简单说它是一个统一的 API 通道把多家模型的调用收敛到一套接口规范下。你拿到一个 Key改一下model字段就能换模型。对前端来说这意味着一份请求代码可以复用到多个模型上做回归测试时特别省心。适合谁需要频繁调用模型做代码生成、用例枚举、文本处理的开发者。如果你只是偶尔问一次问题用网页版就行但如果你要把调用嵌进工作流统一 Key 通道能省掉大量配置成本。前置准备分三步。第一步获取 Key。访问控制台页面创建 API Keyhttps://taotoken.net/console/api-keys?utm_sourcetaotoken_aicg_blog_endutm_contentapi_keysutm_campaignrewrite创建后复制那串sk-开头的字符串只显示一次记得存好。第二步确认 Base URL。所有请求走这个地址注意它和官网首页不同https://taotoken.net/api第三步选模型。在模型对话页可以先试跑确认你要用的模型 IDhttps://taotoken.net/models?utm_sourcetaotoken_aicg_blog_endutm_contentmodelsutm_campaignrewrite这里有个关键点Base URL、Key、Model ID 三件套必须配套使用。很多 401 报错就是因为 Base URL 填了官网首页而不是/api或者 Key 复制时带了空格。我试过把 Key 末尾的空格带进去排查了十分钟才发现。配置方式有两种。如果你用命令行工具或 SDK通常写进配置文件如果直接发 HTTP 请求就在 header 里带Authorization: Bearer 你的Key。以常见的 OpenAI 兼容格式为例请求体长这样{ model: 你选定的模型ID, messages: [ { role: user, content: 列出 textarea 光标操作的 10 个边界测试用例每条包含输入状态和预期 selectionStart/selectionEnd } ] }注意model字段填的是你在模型列表里看到的 ID不是随便写的名字。填错会返回模型不存在的错误。如果你用的是支持自定义 Base URL 的编辑器插件或 CLI 工具配置项一般长这样以 TOML 为例[provider] base_url https://taotoken.net/api api_key sk-你的Key model 你选定的模型ID把这三项填对后面生成测试用例的请求就能跑通。这一步不涉及任何复杂网络配置就是标准的 HTTP 调用你在浏览器里用 fetch 也能直接发。3. 可复制配置input/textarea 与 contenteditable 的光标读写函数这一节是全文的核心直接给可复制的代码。我按元素类型分成两组每组都有「读」和「写」两个函数并且标注了兼容性注意点。3.1 input/textarea 的读写selectionStart 与 setSelectionRange先看读取。input和textarea的光标位置通过selectionStart和selectionEnd获取。当两者相等时是光标无选区不等时是选中了一段文本。/** * 读取 input/textarea 的光标或选区位置 * param {HTMLInputElement|HTMLTextAreaElement} el * returns {{start: number, end: number, selected: string} | null} */ function getCursorPosition(el) { if (!el) return null; // 非文本类型的 input 不支持 selectionStart if (el.tagName INPUT ![text, search, url, tel, password].includes(el.type)) { return null; } const start el.selectionStart; const end el.selectionEnd; if (start null || end null) return null; return { start, end, selected: el.value.substring(start, end) }; }这里有个细节selectionStart在空输入框上返回0不是null。只有不支持的类型才返回null。所以判断条件要写清楚。再看写入。设置光标用setSelectionRange(start, end)它比分别赋值selectionStart和selectionEnd更可靠因为后者在某些浏览器上会触发两次重绘。/** * 设置 input/textarea 的光标位置或选区 * param {HTMLInputElement|HTMLTextAreaElement} el * param {number} start * param {number} [end] */ function setCursorPosition(el, start, end start) { if (!el) return; el.focus(); // 边界保护不能超过文本长度 const len el.value.length; const s Math.max(0, Math.min(start, len)); const e Math.max(0, Math.min(end, len)); el.setSelectionRange(s, e); }注意el.focus()必须在setSelectionRange之前调用。如果元素没有焦点部分浏览器会忽略选区设置。这是实测出来的顺序要求。现在把「在光标处插入文本并重新定位」串起来这是最常用的组合场景/** * 在光标处插入文本并把光标移到插入内容之后 * param {HTMLInputElement|HTMLTextAreaElement} el * param {string} text */ function insertAtCursor(el, text) { const pos getCursorPosition(el); if (!pos) return; const oldValue el.value; const newValue oldValue.substring(0, pos.start) text oldValue.substring(pos.end); el.value newValue; // 光标落在插入内容末尾 const newCursor pos.start text.length; setCursorPosition(el, newCursor); }这段代码有个容易忽略的点直接改el.value会丢失原生的撤销栈。如果你需要保留 CtrlZ 撤销能力得用document.execCommand(insertText, false, text)但它已被标记为废弃。取舍看你项目对撤销的要求。3.2 contenteditable 的读写Range 与 Selection 对象contenteditable元素没有selectionStart必须走Selection和Range。先理解这两个对象的关系Selection代表用户当前的选择状态一个文档只有一个Range代表一段连续区间Selection可以包含多个Range但实际使用中通常只有一个。读取光标位置/** * 读取 contenteditable 元素内的光标偏移量相对于元素起始 * param {HTMLElement} el * returns {{start: number, end: number, collapsed: boolean} | null} */ function getEditableCursor(el) { const selection window.getSelection(); if (!selection || selection.rangeCount 0) return null; const range selection.getRangeAt(0); // 确认选区确实在目标元素内 if (!el.contains(range.startContainer)) return null; // 计算相对于元素起点的偏移 const preRange range.cloneRange(); preRange.selectNodeContents(el); preRange.setEnd(range.startContainer, range.startOffset); const start preRange.toString().length; const end start range.toString().length; return { start, end, collapsed: range.collapsed }; }这里的关键是preRange那段range.startOffset是相对于它直接父节点的偏移不是相对于整个contenteditable的。所以要先克隆一个从元素开头到光标处的区间用toString().length算出全局偏移。这个转换是contenteditable光标操作最容易写错的地方。设置光标位置/** * 在 contenteditable 元素内设置光标到指定文本偏移处 * param {HTMLElement} el * param {number} offset */ function setEditableCursor(el, offset) { el.focus(); const selection window.getSelection(); const range document.createRange(); let charCount 0; let nodeStack [el]; let node, found false; // 深度优先遍历文本节点定位到 offset 所在的节点 while (!found (node nodeStack.pop())) { if (node.nodeType Node.TEXT_NODE) { const nextCount charCount node.length; if (offset nextCount) { range.setStart(node, offset - charCount); range.setEnd(node, offset - charCount); found true; } charCount nextCount; } else { // 子节点逆序入栈保证从左到右遍历 for (let i node.childNodes.length - 1; i 0; i--) { nodeStack.push(node.childNodes[i]); } } } if (!found) { // offset 超出内容长度落到末尾 range.selectNodeContents(el); range.collapse(false); } selection.removeAllRanges(); selection.addRange(range); }这段遍历逻辑是必须的因为contenteditable的内容可能被拆成多个文本节点比如中间有span或br。你不能假设只有一个文本节点。3.3 配置片段把三件套写进项目如果你要把 TaoToken 的调用集成进前端项目做测试用例生成配置可以放在环境变量或配置文件里。以.env风格为例TAOTOKEN_BASE_URLhttps://taotoken.net/api TAOTOKEN_API_KEYsk-你的Key TAOTOKEN_MODEL你选定的模型ID然后在请求里读取const res await fetch(${import.meta.env.TAOTOKEN_BASE_URL}/chat/completions, { method: POST, headers: { Content-Type: application/json, Authorization: Bearer ${import.meta.env.TAOTOKEN_API_KEY} }, body: JSON.stringify({ model: import.meta.env.TAOTOKEN_MODEL, messages: [{ role: user, content: 生成 textarea 光标边界用例 }] }) });Base URL、Key、Model ID 三件套齐全请求才能通。缺任何一个都会报错下面排障章节会逐个对照。4. 验证请求与成功结果用模型生成边界用例并跑通回归配置好之后怎么确认整条链路是通的我分两步验证先验证 API 调用本身再验证光标函数在真实用例下的表现。4.1 验证 API 调用发一个最小请求看返回结构。用 curl 最直观curl https://taotoken.net/api/chat/completions \ -H Content-Type: application/json \ -H Authorization: Bearer sk-你的Key \ -d { model: 你选定的模型ID, messages: [{role: user, content: 回复 OK 两个字母即可}] }成功的话返回体里会有choices数组第一个元素的message.content就是模型回复。你看到类似这样的结构就说明通了{ choices: [ { message: { role: assistant, content: OK } } ] }如果返回里没有choices或者报reading choices之类的错误说明响应结构不对多半是 Base URL 或模型 ID 有问题对照下一节排查。4.2 用模型生成光标边界用例链路通了之后让模型帮你列测试用例。请求内容可以这样写const prompt 为 textarea 光标操作列出边界测试用例每条包含 1. 输入框初始内容 2. 初始光标位置 3. 执行的操作 4. 预期 selectionStart 和 selectionEnd 覆盖空输入、全选、末尾、emoji、换行符、选区跨越标签等场景。用表格输出。;模型返回的用例表可以直接转成测试代码。比如它可能给出「输入abc光标在 0执行右移一位预期 start2」——因为 emoji 占两个 UTF-16 码元。这类用例手写很容易漏模型列得比较全。4.3 跑通回归验证拿到用例后写一个简单的断言函数function assertCursor(el, expectedStart, expectedEnd, label) { const pos getCursorPosition(el); const pass pos pos.start expectedStart pos.end expectedEnd; console.log(${pass ? PASS : FAIL} - ${label}: got ${pos?.start},${pos?.end} expect ${expectedStart},${expectedEnd}); return pass; } // 用例空输入框光标应在 0 const ta document.getElementById(testTextarea); ta.value ; setCursorPosition(ta, 0); assertCursor(ta, 0, 0, 空输入框光标归零); // 用例末尾插入 ta.value hello; setCursorPosition(ta, 5); insertAtCursor(ta, world); assertCursor(ta, 11, 11, 末尾插入后光标在末尾);实测下来textarea的用例基本都能过。contenteditable的用例要复杂些因为不同浏览器对空元素的光标处理有差异——比如空的contenteditable里rangeCount可能是 0需要先插入一个占位文本节点。这个差异我在下一节展开。成功结果长这样控制台连续输出 PASS没有 FAIL。如果有 FAIL看是哪个用例对照预期值和实际值定位是读函数还是写函数的问题。5. 本篇常见错排查401、local proxy failed、reading choices、OAuth 逐个对照这一节按真实报错来。我把调用 TaoToken 和光标操作两类问题分开列每条都给现象、原因、解法。5.1 401 Unauthorized现象请求返回 401提示未授权。原因通常有三个。第一Key 没带或带错比如Authorizationheader 写成了Bearer后面没空格或者 Key 复制时带了换行。第二Key 已失效或被删除去控制台确认状态。第三Base URL 写成了官网首页而不是/api导致请求打到了错误端点。解法重新复制 Key确认 header 格式是Authorization: Bearer sk-xxxBase URL 用https://taotoken.net/api。三件套里 Key 和 Base URL 是最容易出错的。5.2 local proxy failed现象请求发不出去报本地代理失败。原因你的开发环境配置了本地代理但代理没启动或端口不对。这不是 TaoToken 的问题是本地网络配置问题。解法检查你的代理设置确认代理服务在运行。如果你不需要代理把相关环境变量清掉。注意这里说的是开发工具自身的代理配置不是让你去搞什么网络工具就是检查本地设置。5.3 reading choices / Cannot read properties of undefined现象代码报Cannot read properties of undefined (reading choices)。原因响应体结构和你预期的不一样。可能是请求失败返回了错误对象没有choices字段也可能是模型 ID 填错导致返回了错误信息。你直接访问res.choices[0]就会报这个错。解法先打印完整响应体看实际返回了什么。如果是错误对象里面会有error.message说明原因。确认模型 ID 在模型列表里存在且 Base URL 正确。const data await res.json(); if (!data.choices) { console.error(响应异常:, JSON.stringify(data)); return; }加这层判断能避免直接崩。5.4 OAuth 相关报错现象提示 OAuth 认证失败或 token 过期。原因如果你用的是某些 CLI 工具它可能走 OAuth 流程而不是 API Key。OAuth token 有有效期过期后需要重新授权。解法确认你的工具是用 API Key 还是 OAuth。如果用 API Key检查配置里是不是误填了 OAuth 相关字段。重新走一遍授权流程或者改用 API Key 方式配置三件套。5.5 光标操作特有报错除了 API 报错光标函数本身也有几个高频问题。selectionStart返回null检查input的typenumber、email、date等类型不支持。换成typetext或改用contenteditable。contenteditable里rangeCount为 0元素没有焦点或者内容为空。先el.focus()空内容时插入一个零宽字符或br占位。设置光标后位置不对contenteditable里range.startOffset是相对父节点的不是相对整个元素的。用第 3 节的preRange方法转换。emoji 位置偏移emoji 占两个 UTF-16 码元selectionStart按码元计数。如果你按字符数算会差一位。用Array.from(str).length算字符数但设置光标时仍要用码元偏移。6. 语义一致 CTA把光标函数和统一 Key 通道用起来光标操作本身不复杂复杂的是边界情况和跨元素兼容。你现在手里有了两组可直接复制的读写函数覆盖input/textarea和contenteditable还有一套用模型生成测试用例的方法。接下来怎么走看你的场景。如果你正在做 AI 对话输入框需要频繁调用模型处理用户输入比如润色、翻译、补全那 Coding Plan 更适合你它面向长期编码和 Agent 场景https://taotoken.net/coding-plan?utm_sourcetaotoken_aicg_blog_endutm_contentcoding_planutm_campaignrewrite如果你只是想先验证某个模型对光标用例的生成效果去模型对话页直接试https://taotoken.net/models?utm_sourcetaotoken_aicg_blog_endutm_contentmodelsutm_campaignrewrite如果你在配置过程中遇到 401 或响应结构问题接入文档里有完整的参数说明https://taotoken.net/doc?utm_sourcetaotoken_aicg_blog_endutm_contentdocutm_campaignrewrite需要新建或管理 Key去控制台https://taotoken.net/console/api-keys?utm_sourcetaotoken_aicg_blog_endutm_contentapi_keysutm_campaignrewrite最后给一个实用技巧把第 3 节的getCursorPosition和setCursorPosition封装成一个模块在项目里统一引用。contenteditable的版本单独放一个文件因为它的依赖Selection、Range和表单控件版本完全不同。这样你在切换输入框类型时只需要换引用不用改调用逻辑。测试用例那边把模型生成的表格存成 JSON写个循环跑断言每次改光标函数后跑一遍回归成本很低。
返回列表