构建 Chrome 应用版代码编辑器:mini-code-edit 示例深度解析(CodeMirror + chrome.fileSystem)
【免费下载链接】chrome-extensions-samplesChrome Extensions Samples项目地址: https://gitcode.com/gh_mirrors/ch/chrome-extensions-samples
本篇技术指南以 chrome-extensions-samples 仓库中_archive/apps/samples/mini-code-edit示例为对象,剖析一个非平凡(non-trivial)Chrome App 代码编辑器的完整实现。该示例展示了如何在打包应用(Packaged App)中集成 CodeMirror 编辑器,实现语法检测(syntax detection)与语法高亮(syntax highlight),并借助扩展版 FileSystem API(chrome.fileSystem)让用户从磁盘选取文件、应用即可对该文件执行读取与写入。读完本文,你将掌握chrome.fileSystem打开/保存文件的完整调用链、基于文件扩展名的语法模式自动切换方案,以及应用窗口、快捷键命令与上下文菜单的组织方式。
示例概览:一个麻雀虽小五脏俱全的代码编辑器
mini-code-edit是 Chrome Apps 时代的代表性示例:它以 640×400(建议)的独立应用窗口呈现一个可用的代码编辑器,核心功能包括:
- 语法检测与高亮:根据文件扩展名自动识别 JSON / HTML / CSS / JavaScript 语法模式;
- 文件系统读写:通过
chrome.fileSystem.chooseEntry()打开磁盘上的文件,读取内容进编辑器,编辑后回写磁盘; - 多窗口与快捷键:通过
chrome.app.window.create()创建窗口,并注册全局命令(Ctrl+Shift+1)随时新建窗口; - 代码片段注入:通过上下文菜单(
chrome.contextMenus)将预置的 Hello World、Servo 串口示例等代码片段插入光标处。
官方 README(_archive/apps/samples/mini-code-edit/README.md)将其定位为“非平凡示例”,即它不是一行 hello world,而是将若干真实 API 组合成一个可实际使用的应用。应用运行的界面效果见官方截图:
从截图可以看到:顶部工具栏提供 New / Open / Save 三个按钮,中央是带行号的深色代码编辑区(当前示例编辑的是index.js),底部状态栏显示Filename与Mode两项信息,这正是该示例"语法检测"功能的直观体现。
用到的 API 与权限设计
README 明确列出本示例依赖的三类 API,它们在 manifest 与源码中有清晰的落点:
| API | 用途 | 仓库中的证据 |
|---|---|---|
chrome.fileSystem | 让用户选择磁盘文件,应用获得读取/写入该文件的权限 | editor.js中的chrome.fileSystem.chooseEntry() |
chrome.app.runtime | 应用启动(onLaunched)时创建主窗口 | background.js |
chrome.app.window | 创建、管理应用窗口(尺寸、边界约束) | background.js与editor.js中的chrome.app.window.create() |
Manifest(_archive/apps/samples/mini-code-edit/manifest.json)中的权限声明如下:
{ "name": "MiniCodeEdit", "version": "0.1.12", "manifest_version": 2, "minimum_chrome_version": "23", "description": "A very small code editor.", "app": { "background": { "scripts": ["background.js"] } }, "permissions": [ {"fileSystem": ["write"]}, "unlimitedStorage", "contextMenus" ], "icons": { "16": "img/16x16/file_edit.png", "32": "img/32x32/file_edit.png", "64": "img/64x64/file_edit.png", "128": "img/128x128/file_edit.png" }, "commands" : { "cmdNew": { "suggested_key": { "default": "Ctrl+Shift+1" }, "global": true, "description": "Create new window" } } }关键配置点说明:
{"fileSystem": ["write"]}:chrome.fileSystem权限必须显式声明写权限;若缺少"write",应用只能读取用户选择的文件而无法回写。这正是本示例能"读写磁盘文件"的权限基础。unlimitedStorage:解除应用本地数据存储的配额限制(配合 FileSystem API 的写入操作,避免 QUOTA_EXCEEDED)。contextMenus:用于注册代码片段注入的右键菜单。commands.cmdNew+global: true:注册一个全局命令(Ctrl+Shift+1),即使应用窗口不在前台也能触发"新建窗口"动作。minimum_chrome_version: "23":声明需要 Chrome 23+(彼时chrome.fileSystem扩展 API 的可用版本前提)。- 应用入口:
app.background.scripts指向background.js,这是 Chrome App 生命周期(onLaunched)的起点。
图标资源按 16 / 32 / 64 / 128 四档尺寸提供,存放于_archive/apps/samples/mini-code-edit/img/目录。
应用入口:窗口创建与全局命令
background.js是应用的后台脚本,包含两个核心监听器:
1. 启动事件chrome.app.runtime.onLaunched——应用被启动时创建主窗口:
chrome.app.runtime.onLaunched.addListener(function() { // width 640 for font size 12 // 720 for font size 14 chrome.app.window.create('main.html', { frame: 'chrome', id: "codewin", innerBounds: { width: 720, height: 400, minWidth:720, minHeight: 400 } }); });窗口参数要点:
frame: 'chrome':使用系统原生窗口边框(相对无边框窗口'none');id: "codewin":窗口标识符,Chrome 会据此恢复窗口状态(同一 id 的窗口在会话间维持几何状态与单实例约束);innerBounds: { width: 720, height: 400, minWidth: 720, minHeight: 400 }:限定窗口内边界尺寸,同时设置最小宽高,保证编辑器布局不塌陷。源码注释提醒:字号 12 时窗口宽 640 即可,字号 14 时需 720——这是对 CodeMirror 渲染宽度的经验取值。
2. 命令事件chrome.commands.onCommand——监听 manifest 中注册的cmdNew命令:
chrome.commands.onCommand.addListener(function(command) { console.log("Command triggered: " + command); if (command == "cmdNew") { chrome.app.window.create('main.html', { frame: 'chrome', id: "codewin", innerBounds: { width: 720, height: 400, minWidth:720, minHeight: 400 } }); } });注意一个细节:由于窗口id同为"codewin",当该 id 的窗口已存在时,chrome.app.window.create()会聚焦已有窗口而不是无限叠加新窗口;而editor.js的handleNewButton()中其实也保留了一个false分支的"清空当前编辑器新建文件"逻辑,从代码结构看,示例最终选择了"新建窗口"路径,体现了两种"新建"策略的取舍。
界面骨架:main.html 与样式
main.html是编辑器唯一的窗口页面,结构非常精简:
<script src="snippets.js"></script> <script src="editor.js"></script> <script src="cm/lib/codemirror.js"></script> <script src="cm/mode/css/css.js"></script> <script src="cm/mode/xml/xml.js"></script> <script src="cm/mode/javascript/javascript.js"></script> <script src="cm/mode/htmlmixed/htmlmixed.js"></script> <link rel="stylesheet" href="style.css"> <link rel="stylesheet" href="cm/lib/codemirror.css"> <link rel="stylesheet" href="cm/theme/lesser-dark.css">加载顺序很讲究:先加载应用逻辑(snippets.js、editor.js),再加载 CodeMirror 核心库与各语言模式模块,最后引入样式与主题。本示例内置了完整的 CodeMirror 2 发行包(位于_archive/apps/samples/mini-code-edit/cm/),包含lib/codemirror.js、十余种语言模式(css、xml、javascript、htmlmixed、python、ruby、go 等)、theme/下的 12 套主题(ambiance、eclipse、monokai、lesser-dark 等)以及keymap/下的 emacs / vim 键位映射。本文示例仅按需加载了 4 个模式与lesser-dark主题,其余模式可按需扩展。
正文的 DOM 结构只有三块:
<div class="buttons"> <button id="new"> <img src="img/16x16/file_add.png"/> New </button> <button id="open"><img src="img/16x16/file.png"/> Open </button> <button id="save"><img src="img/16x16/diskette.png"/> Save </button> </div> <div id="editor"></div> <div class="info"> <label>Filename: </label><span id="title"></span> <label>Mode: </label><span id="mode"></span> </div>- 工具栏:New(新建)、Open(打开)、Save(保存)三个按钮,配 16×16 图标(
_archive/apps/samples/mini-code-edit/img/16x16/); #editor:CodeMirror 的挂载容器;.info:底部状态栏,动态显示当前文件名(#title)与语法模式(#mode)。
style.css通过绝对定位将#editor铺满工具栏与状态栏之间的区域(top: 29px; bottom: 24px),并隐藏页面滚动(overflow: hidden),让编辑器滚动条接管交互;.CodeMirror-scroll被设置为纵向隐藏、横向自动,配合onresize手动同步容器尺寸。
核心逻辑:editor.js 的读写闭环
editor.js是本示例的灵魂,完整实现了"打开 → 读取 → 编辑 → 保存"的闭环。
全局状态与错误处理
var newButton, openButton, saveButton; var editor; var fileEntry; // 当前绑定的文件句柄(chrome.fileSystem 的 Entry) var hasWriteAccess; // 当前文件是否可写fileEntry与hasWriteAccess是读写权限模型的核心:通过openWritableFile打开的文件可写;通过只读方式打开的文件则只能查看。errorHandler()将FileError错误码(QUOTA_EXCEEDED_ERR、NOT_FOUND_ERR、SECURITY_ERR、INVALID_MODIFICATION_ERR、INVALID_STATE_ERR)映射为可读文本并输出到控制台,是典型的 FileSystem API 错误处理模板。
语法检测:handleDocumentChange
function handleDocumentChange(title) { var mode = "javascript"; var modeName = "JavaScript"; if (title) { title = title.match(/[^/]+$/)[0]; // 截取文件名(去掉路径) document.getElementById("title").innerHTML = title; document.title = title; // 同步窗口标题 if (title.match(/.json$/)) { mode = {name: "javascript", json: true}; // JSON 复用 JS 模式 + json 标志 modeName = "JavaScript (JSON)"; } else if (title.match(/.html$/)) { mode = "htmlmixed"; modeName = "HTML"; } else if (title.match(/.css$/)) { mode = "css"; modeName = "CSS"; } } else { document.getElementById("title").innerHTML = "[no document loaded]"; } editor.setOption("mode", mode); document.getElementById("mode").innerHTML = modeName; }语法检测策略一目了然:
- 默认语法模式为 JavaScript(
modeName: "JavaScript"); .json→ 复用 CodeMirror 的 javascript 模式并开启json: true标志,界面显示 "JavaScript (JSON)";.html→htmlmixed模式(HTML 混合模式,可同时高亮内嵌的 CSS 与 JS);.css→css模式;- 其余扩展名一律回退到 JavaScript。
这种"按扩展名映射到 CodeMirror mode"的实现,正是 README 所述 "syntax detection" 的落地方式;高亮则由 CodeMirror 各模式模块(cm/mode/下加载的css.js、xml.js、javascript.js、htmlmixed.js)负责。
读取文件:readFileIntoEditor
function readFileIntoEditor(theFileEntry) { if (theFileEntry) { theFileEntry.file(function(file) { var fileReader = new FileReader(); fileReader.onload = function(e) { handleDocumentChange(theFileEntry.fullPath); editor.setValue(e.target.result); }; fileReader.onerror = function(e) { console.log("Read failed: " + e.toString()); }; fileReader.readAsText(file); }, errorHandler); } }读取链路为:Entry.file() → FileReader.readAsText() → handleDocumentChange() 检测语法 → editor.setValue() 载入内容。这里使用标准的 HTML5FileReader读取文本,用Entry.fullPath作为文件名来源。
写入文件:writeEditorToFile
function writeEditorToFile(theFileEntry) { theFileEntry.createWriter(function(fileWriter) { fileWriter.onerror = function(e) { console.log("Write failed: " + e.toString()); }; var blob = new Blob([editor.getValue()]); fileWriter.truncate(blob.size); fileWriter.onwriteend = function() { fileWriter.onwriteend = function(e) { handleDocumentChange(theFileEntry.fullPath); console.log("Write completed."); }; fileWriter.write(blob); } }, errorHandler); }写入链路为:editor.getValue() → 构造 Blob → createWriter() → truncate() 截断旧内容 → write() 写入新内容。写入完成后再次调用handleDocumentChange()刷新标题与模式状态。truncate在write之前执行,确保新内容比旧内容短时文件不会残留尾部旧数据——这是 FileWriter 覆盖写入的标准姿势。
打开 / 保存的用户路径
chrome.fileSystem的三个选择入口分别绑定不同回调:
var onChosenFileToOpen = function(theFileEntry) { setFile(theFileEntry, false); // 只读打开 readFileIntoEditor(theFileEntry); }; var onWritableFileToOpen = function(theFileEntry) { setFile(theFileEntry, true); // 可写打开 readFileIntoEditor(theFileEntry); }; var onChosenFileToSave = function(theFileEntry) { setFile(theFileEntry, true); writeEditorToFile(theFileEntry); }; function handleOpenButton() { chrome.fileSystem.chooseEntry({ type: 'openWritableFile' }, onWritableFileToOpen); } function handleSaveButton() { if (fileEntry && hasWriteAccess) { writeEditorToFile(fileEntry); // 已持有可写句柄,直接写回 } else { chrome.fileSystem.chooseEntry({ type: 'saveFile' }, onChosenFileToSave); } }handleSaveButton()体现了权限模型的关键分支:
- 若当前文件已绑定且
hasWriteAccess为真,直接写入原文件,不弹选择框; - 否则(新文件,或只读打开的文件)调用
chrome.fileSystem.chooseEntry({ type: 'saveFile' })让用户指定保存位置。
chrome.fileSystem.chooseEntry()的type取值在本示例中出现两种:'openWritableFile'(以可写方式打开已有文件)与'saveFile'(选择/新建保存目标)。需要说明的是:type: 'saveFile'在较新的 API 版本中已被'saveFile'的许可模式细化,示例本身按旧版 API 编写,但其"读写权限分离、按需申请"的设计思路在 Manifest V3 的chrome.fileSystem(offscreen 场景)中依然适用。
窗口内的"新建":handleNewButton
function handleNewButton() { if (false) { newFile(); // 预留的清空当前编辑器实现(未启用) editor.setValue(""); } else { chrome.app.window.create('main.html', { frame: 'chrome', id: "codewin", innerBounds: { width: 720, height: 400} }); } }从源码结构看,newFile()(置空fileEntry与hasWriteAccess,显示[no document loaded])是"当前窗口内新建空白文件"的实现,但示例通过if (false)关闭了该分支,实际行为是创建新的编辑窗口——两种新建策略都完整保留在代码中,便于对照学习。
代码片段:上下文菜单与 SNIPPETS
snippets.js定义了一个SNIPPETS对象,每个键是菜单标题,值是待插入的代码文本,例如:
"Hello World: Manifest": '{\n "manifest_version": 2,\n "name": "Hello World",\n ... }\n', "Hello World: main.js": "chrome.app.runtime.onLaunched.addListener(function() {\n chrome.app.window.create('window.html', {\n bounds: { \n width: 400,\n height: 400\n }});\n})", "Servo: onRead": "function onRead(readInfo) { ... chrome.serial.read(connectionId, onRead); };"文件头注释说明这些片段源自 2012 年 6 月 Google I/O 演示,API 已演进,片段不保证可用——它们更多是"教学示例素材"而非"可运行代码"。editor.js中的注入机制:
function initContextMenu() { chrome.contextMenus.removeAll(function() { for (var snippetName in SNIPPETS) { chrome.contextMenus.create({ title: snippetName, id: snippetName, contexts: ['all'] }); } }); } chrome.contextMenus.onClicked.addListener(function(info) { // Context menu command wasn't meant for us. if (!document.hasFocus()) { return; } editor.replaceSelection(SNIPPETS[info.menuItemId]); });机制要点:
- 应用启动时(
onload)调用initContextMenu(),先removeAll()清空再逐个创建菜单项,避免重复注册; - 每个片段以
title显示在右键菜单中,id用片段名充当; - 点击回调先检查
document.hasFocus(),确保菜单事件确实发生在当前编辑窗口内(上下文菜单可能来自应用的其他页面); - 最终
editor.replaceSelection()将片段插入编辑器光标处——这是 CodeMirror 的选区替换 API。
CodeMirror 集成:编辑器初始化与自适应布局
editor.js的onload中完成编辑器装配:
editor = CodeMirror( document.getElementById("editor"), { mode: {name: "javascript", json: true }, lineNumbers: true, theme: "lesser-dark", fixedGutter: true, extraKeys: { "Cmd-S": function(instance) { handleSaveButton() }, "Ctrl-S": function(instance) { handleSaveButton() }, } }); newFile(); onresize();关键选项:
lineNumbers: true:显示行号(与截图一致);theme: "lesser-dark":使用cm/theme/lesser-dark.css定义的深色主题;fixedGutter: true:行号槽固定,水平滚动时行号不随内容滚动;extraKeys:注册Cmd-S/Ctrl-S快捷键直接触发保存——把桌面编辑器的肌肉记忆搬进 Web 应用;- 初始模式为 JSON 形态的 JavaScript(
{name: "javascript", json: true})。
布局适配由onresize完成:
onresize = function() { var container = document.getElementById('editor'); var containerWidth = container.offsetWidth; var containerHeight = container.offsetHeight; var scrollerElement = editor.getScrollerElement(); scrollerElement.style.width = containerWidth + 'px'; scrollerElement.style.height = containerHeight + 'px'; editor.refresh(); }它监听窗口resize,将 CodeMirror 滚动容器(getScrollerElement())的宽高同步为#editor容器的实际尺寸,再调用editor.refresh()重绘。这与style.css中#editor的绝对定位布局配合,保证编辑器在窗口缩放时始终铺满可用空间。
整体架构与调用链梳理
综合源码,本示例的运行流程可归纳为:
Chrome 启动应用 └─ chrome.app.runtime.onLaunched ──→ background.js 创建窗口(main.html) └─ 页面 onload ──→ 初始化 CodeMirror + 上下文菜单 用户操作: ├─ New ──→ chrome.app.window.create 新开编辑窗口 ├─ Open ──→ chrome.fileSystem.chooseEntry({type:'openWritableFile'}) │ └─ Entry.file() → FileReader → handleDocumentChange(语法检测) → editor.setValue ├─ Save ──→ 已有可写句柄 ? 直接写回 : chooseEntry({type:'saveFile'}) │ └─ createWriter → truncate → write(Blob) ├─ Ctrl+Shift+1 ──→ chrome.commands.onCommand("cmdNew") → 新窗口 └─ 右键菜单 ──→ chrome.contextMenus.onClicked → editor.replaceSelection(SNIPPETS[id])技术栈分层清晰:
- 应用外壳层:
chrome.app.runtime/chrome.app.window/chrome.commands(background.js); - 文件权限层:
chrome.fileSystem+ HTML5FileReader/FileWriter(editor.js); - 编辑交互层:CodeMirror 2(
cm/内置发行包)与页面布局(main.html、style.css); - 内容增强层:上下文菜单注入代码片段(
snippets.js)。
运行与验证
该示例位于仓库_archive/apps/samples/mini-code-edit/,属 Manifest V2 时代的 Chrome Apps 形态(manifest_version: 2),运行方式为:在 Chrome 的扩展程序页开启"开发者模式"→ 选择"加载已解压的扩展程序" → 指向该目录,随后从应用启动器打开 MiniCodeEdit。需要说明的适用前提:
- 示例声明
minimum_chrome_version: "23",依赖当时稳定的 Chrome Apps /chrome.fileSystem扩展 API; - Chrome Apps 已被 Chrome 官方逐步停用(2021 年起 Chrome 93 之后不再支持 Chrome Apps),因此该示例当前主要作为源码学习与 API 用法参考,而不是可长期部署的运行时方案;
- 其核心知识点——
chrome.fileSystem的读写权限模型、FileReader/FileWriter的读写闭环、CodeMirror 的按需模式加载与快捷键绑定——对现代 Web 应用与扩展开发(如 MV3 中借助 offscreen 文档使用chrome.fileSystem)依然具有直接的迁移价值。
小结
mini-code-edit用不到 400 行代码把"语法检测 + 语法高亮 + 磁盘文件读写"三个能力组合成了一个可用的代码编辑器,是学习以下技能的极佳范本:
chrome.fileSystem权限模型:openWritableFile/saveFile两种选择入口与hasWriteAccess状态机,理解"应用如何安全地读写用户指定文件";- 语法检测与高亮:扩展名 → CodeMirror mode 的映射表实现,以及
json: true、htmlmixed等模式的精细配置; - 桌面化体验:全局命令(
Ctrl+Shift+1)、Cmd/Ctrl-S保存快捷键、上下文菜单片段注入、窗口尺寸约束与自适应布局。
仓库中还保留着完整的 CodeMirror 2 源码包(_archive/apps/samples/mini-code-edit/cm/下的doc/manual.html、test/、mode/、theme/),读者可以继续深入 CodeMirror 的 API 文档与测试用例,将该编辑器示例扩展出查找替换、折叠、Emacs/Vim 键位(cm/keymap/)等更多能力。
【免费下载链接】chrome-extensions-samplesChrome Extensions Samples项目地址: https://gitcode.com/gh_mirrors/ch/chrome-extensions-samples
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考