项目地址:ccr39/NicoNote: 从零开始编写的浏览器笔记脚本

故事的开始:

在浏览器上学习时时常要记笔记。

在预览网络上已有的各类插件后本人发现,这类插件往往已经长时间失去维护,或需要付费使用。

为什么不自己制作一个有类似功能的脚本呢?

在几乎没有任何理论知识的前提下本人决定实施这个构想。

一、开始前的准备

希望实现的功能

基本功能:

  • 能够在网页上编写笔记,并保存在本地。可以在打开网页后显示对应笔记。
  • 能够使用 Markdown 语法并实现实时渲染 $\LaTeX$ 。
  • 能够导出与导入 Markdown 笔记。
  • 能够调整笔记框的大小。

进阶功能:

  • 美化笔记框背景。
  • 可以根据网页中的某条内容链接到笔记的某一部分。
  • 可以根据视频的进度链接到笔记。
  • 自定义笔记框。
  • (待思考)

实现方案

1、使用 Tampermonkey 编写基本主体脚本。这需要学习一部分网页前端知识。

2、使用开源的纯前端 Markdown 文字编辑器。

3、利用 Git 进行版本控制与脚本的发布。

二、让我们开始吧

尝试了解 Tampermonkey,发现官方文档没有进行汉化,因此翻阅了中文的油猴开发指南

1、对头部元数据的理解

元数据
@name( :zh-CN :zh-TW :en ) 脚本的名字(简中,繁中,英文)。
@namespace 一般为一个 URL 或自定义的内容,用于脚本管理器在@name相同的情况下识别唯一的脚本
@version 脚本的版本。
@author 脚本作者。
@description( :zh-CN :zh-YW :en ) 脚本的描述(简中,繁中,英文)。
@include 脚本应该运行的页面, 可以使用通配符, 允许多个标签。
@match 与@include相似,区别是不可以用通配符,更精确因此更为常用,允许多个标签。
@exclude 排除URL,即使它们包含在@include或@match中 ,允许多个标签。
@icon @iconURL @defaulticon 脚本的图标的 URL,会在脚本管理列表上显示。
@icon64 @icon64URL 64*64 的图标文件的 URL ,可能会在选项中缩放。
@updateURL 更新脚本的地址,该URL应该指向一个脚本文件,只需要定期更新该文件即可实现对脚本的更新。注意:只有存在@version标签才会去更新。
@downloadURL 定义脚本的下载地址(一般指的是手动安装脚本时的文件)通常和 @updateURL 指向同一文件较常见。
@license 用于声明脚本的许可证类型。许可证定义了其他人如何使用、修改和分发你的脚本。
@grant 声明脚本会用到的特殊权限或功能。它决定了脚本是否可以使用某些特定的 API,或者是否可以访问用户脚本管理器提供的功能。可以存在多条
@require 指向一个脚本文件,会在本脚本运行前加载并执行
@run-at 定义脚本被注入的时间,与其他脚本处理相反,@run-at 定义了脚本要运行的第一可能时间。这意味着,使用@require标记的脚本可能会在文档已加载后执行,因为获取所需脚本花费了很长时间。
@supportURL 插件个人支持的地址,用于提交 issue 。
@homepage @homepageURL @website @source 在“选项”页上用于从脚本名链接到给定页的作者主页。请注意,如果@namespace标记以“http://”开头,则其内容也将用于此操作。
@connect 用于声明可以与那些外部的域名进行跨域通信,未声明的域名在脚本中将被禁止访问,允许多个标签。可以使用@connect *允许所有域名(不推荐,可能带来安全风险)。

部分元数据的常用值

①关于@license常见的许可证类型:

许可证 作用/特点
MIT 允许几乎任何使用,包括修改、分发和商业用途,只需保留原作者声明。
GNU GPL-3.0 允许修改和分发,但要求开源修改后的版本,适合需要保持自由传播的软件。
Apache-2.0 类似于 MIT,但要求保留通知文件,并保护专利权。
CC-BY-4.0 允许分享和修改,只需署名原作者,常用于非软件内容。
Unlicense 放弃所有版权,允许任何用途。
WTFPL Do What The Fuck You Want To Public License中文译:你他妈的想干嘛就干嘛公共许可证。是一种不太常用的、极度放任的自由软件许可证。它的条款基本等同于贡献到公有领域。
Custom 自定义声明,允许作者明确指定特殊的版权声明,例如:仅供个人使用,禁止修改传播

@grant 的常用值

@grant 值 描述
none 不授予任何特殊权限,脚本与页面共享相同的全局作用域。
GM_addStyle 向页面动态添加 CSS 样式。
GM_getValue 从脚本的本地存储中获取数据。
GM_setValue 将数据存储到脚本的本地存储中。
GM_deleteValue 删除脚本本地存储中的数据。
GM_listValues 获取所有存储值的键名列表。
GM_xmlhttpRequest 发起跨域的 HTTP 请求(比原生的 XMLHttpRequest 更强大)。
GM_download 下载文件到本地。
GM_notification 在桌面弹出通知。
GM_openInTab 在新标签页中打开一个链接。
GM_registerMenuCommand 向用户脚本菜单添加自定义选项。
GM_unregisterMenuCommand 删除自定义的菜单选项。
GM_setClipboard 将内容复制到系统剪贴板。
GM_info 提供有关用户脚本的信息(只读)。

@run-at的常用值

@run-at 值 描述
document-start 脚本会被尽可能快地注入
document-body 当body元素存在是被注入
document-end 当 DOMContentLoaded 事件被触发时或者之后注入
document-idle 当 DOMContentLoaded 事件被触发后被注入 如果没有@run-at标签也是在此时注入

2、目前产生的问题(ChatGPT 解答)

Q.1:@downloadURL和@updateURL的区别

在编写油猴(Tampermonkey 或 Greasemonkey)脚本时,@include@match 是用来定义脚本作用范围的元数据注释。它们的作用类似,但有一些关键区别:

@include

  • 用途:匹配指定的 URL 模式,让脚本在符合模式的页面上运行。

  • 语法:支持通配符(*),简单易用。

    • *:匹配任意字符。

    • 示例:

       // 脚本会在所有以 "https://example.com/" 开头的页面上运行
      @include https://example.com/*
      
  • 匹配示例:

    • https://example.com/*
      

      可以匹配:

      • https://example.com/page1
    • https://example.com/page2

    • 通配符 *也可以放在中间或末尾,如:

      • https://*.example.com/* 匹配所有子域名。

@match

  • 用途:更严格地匹配 URL 模式,符合 URL 模式规范。

  • 语法:不支持简单的 * 通配符定义所有,而是更精确的匹配规则。

    • 但可以使用通配符 * 匹配子域名或协议。

    • 示例:

       javascript复制代码// 脚本会在 https://example.com 的所有页面运行
      @match https://example.com/*
      
    
    
  • 匹配示例:

    • https://*.example.com/*
      

      可以匹配:

      • https://sub.example.com/page
    • https://another.example.com/page

  • 但不会匹配:

    • http://example.com(因为协议不同)。

关键区别

特性 @include @match
灵活性 支持简单通配符,较宽松 严格匹配 URL,符合标准规范
安全性 容易出现误匹配 更严格,减少误匹配的风险
协议要求 可以省略协议 必须指定协议(http://https://
兼容性 旧版脚本可能使用更多 推荐使用,符合现代浏览器扩展规范

使用建议

  1. 如果需要快速定义作用域,且 URL 规则较简单,可以使用 @include
  2. 如果需要更安全、更准确的匹配,建议使用 @match,特别是在现代浏览器扩展环境下。

现代浏览器和 Tampermonkey 都更推荐使用 @match,因为它的匹配规则符合浏览器扩展的标准。

个人总结

两者用来将脚本应用到所有的页面,应分别如下写:

@match *://*/*

@include *

Q.2:什么是 DOMContentLoaded 事件?

DOMContentLoaded 是一个常见的 JavaScript 事件,用于监听 HTML 文档的加载状态。当浏览器解析完所有的 HTML 并构建完成 DOM 树时,就会触发 DOMContentLoaded 事件,而无需等待其他资源(如图片、样式表和脚本文件)的加载完成。

特点

  1. 触发时机:当 HTML 文档被完全加载和解析完成时(即 DOM 树构建完成)。
  2. 不等待其他资源:与 window.onload 不同,DOMContentLoaded 不需要等待图片、样式表、iframe 等资源加载完成后才触发。
  3. 适用于操作 DOM:在该事件触发时,DOM 已经可用,因此适合初始化页面内容或操作 DOM 元素。

用法

监听 DOMContentLoaded 的方法有以下几种:

1. 使用 addEventListener

document.addEventListener("DOMContentLoaded", function() {
    console.log("DOM 已完全加载并解析");
   // 可以安全地操作 DOM 元素
    document.body.style.backgroundColor = "lightblue";
});

2. 使用旧的 onreadystatechange

早期浏览器中,可以通过 readyState 属性判断文档是否加载完成:

document.onreadystatechange = function() {
    if (document.readyState === "interactive") {
       console.log("DOM 已完全加载并解析");
    }
};

3. 直接在脚本末尾执行

如果你的脚本放在文档的底部(</body>标签之前),可以不显式监听 DOMContentLoaded,因为此时 DOM 已经解析完成:

<!DOCTYPE html>
<html>
<head>
    <title>Example</title>
</head>
<body>
    <h1>Hello, World!</h1>
    <script>
        console.log(document.querySelector("h1").textContent); // "Hello, World!"
    </script>
</body>
</html>

DOMContentLoadedwindow.onload 的区别

特性 DOMContentLoaded window.onload
触发时机 DOM 树构建完成(HTML 已加载并解析)。 页面所有资源(包括图片、样式等)加载完成。
是否等待外部资源 不等待外部资源加载完成。 等待所有外部资源加载完成。
使用场景 初始化 DOM 操作或页面功能。 确保所有资源可用(如图片尺寸)。

示例:两者对比

document.addEventListener("DOMContentLoaded", function() {
    console.log("DOM 已加载完成,但图片可能还在加载中");
});

window.addEventListener("load", function() {
    console.log("页面所有资源加载完成,包括图片和样式");
});

浏览器支持

DOMContentLoaded 是一个标准事件,现代浏览器(包括 IE9+)都完全支持。

使用建议

  • 推荐场景:如果你的脚本只需要操作 DOM 元素(如添加事件监听器或修改内容),优先使用 DOMContentLoaded,因为它比 window.onload 更早触发。
  • 性能优化:通过监听 DOMContentLoaded,可以更快地响应用户交互,而无需等待所有资源加载完成。
Q.3:什么是DOM树?

DOM 树(Document Object Model Tree)是浏览器在加载和解析 HTML 文档时,根据 HTML 的层级结构生成的一种树形数据结构。它表示了文档的结构以及每个节点之间的关系,使得 JavaScript 可以通过 DOM API 操作页面内容和结构。

DOM 树的组成

DOM 树由以下几种节点组成,每个节点代表 HTML 文档的一部分:

  1. 文档节点(Document Node)
    • 表示整个 HTML 文档的根节点。
    • 通过 JavaScript 的 document 对象访问。
  2. 元素节点(Element Nodes)
    • 表示 HTML 的元素标签,例如 <div><p>
    • 元素节点可以嵌套形成层级结构。
  3. 属性节点(Attribute Nodes)
    • 表示 HTML 元素的属性,例如 id="example"
    • 属性是元素节点的一部分,但在 DOM 树中不是子节点,而是直接附属于对应的元素节点。
  4. 文本节点(Text Nodes)
    • 表示元素或属性中的文本内容。
    • 文本节点始终是叶节点,没有子节点。
  5. 注释节点(Comment Nodes)
    • 表示 HTML 文档中的注释内容。

DOM 树的结构

以以下 HTML 代码为例:

<!DOCTYPE html>
<html>
  <head>
    <title>Example</title>
  </head>
  <body>
    <h1 id="header">Hello, World!</h1>
    <p>Welcome to DOM.</p>
  </body>
</html>

对应的 DOM 树结构如下:

Document
 └── html
      ├── head
      │     └── title
      │           └── "Example"
      └── body
            ├── h1
            │     ├── Attribute: id="header"
            │     └── "Hello, World!"
            └── p
                  └── "Welcome to DOM."

DOM 树的特点

  1. 层级关系
  • HTML 文档的嵌套结构会在 DOM 树中反映为父子关系。
  • 每个 HTML 元素是一个节点,嵌套关系形成了树状层级。
  1. 节点类型
    • 可以通过 nodeType 属性查看节点类型,例如:
      • 1:元素节点
      • 3:文本节点
      • 8:注释节点
  2. 可遍历
    • DOM 树中的节点可以通过 JavaScript 遍历,例如 parentNodechildNodesnextSibling 等。

常见操作

  1. 获取节点

     const header = document.getElementById("header"); // 获取 h1 元素
    const paragraphs = document.getElementsByTagName("p"); // 获取所有 <p> 元素
    
2. **修改节点内容**:

 ```javascript
const header = document.getElementById("header");
 header.textContent = "Hello, DOM!";
  1. 添加新节点

     const newDiv = document.createElement("div");
    newDiv.textContent = "This is a new div.";
     document.body.appendChild(newDiv);
    
  2. 删除节点

     const header = document.getElementById("header");
    header.remove();
    
**DOM 树的作用**

1. **页面动态交互**:通过操作 DOM 树,可以动态修改页面的结构和内容。
2. **事件绑定**:JavaScript 事件处理器通过 DOM 树关联 HTML 元素。
3. **样式动态调整**:通过修改 DOM 节点的样式属性实现动态效果。

**总结**

DOM 树是 HTML 文档在浏览器中的表示形式,它将 HTML 结构化为可以用 JavaScript 操作的对象模型。熟练掌握 DOM 树和相关操作是前端开发的基础。

2024/12/8


3、Hello World

在头部元数据下方标注了书写代码的地方。

(function() {
    'use strict';

// Your code here...

})();

根据文档描述,脚本的书写应该使用JavaScript

利用alert()弹出一个对话框,并输出 HelloWorld,并使用@include *将其应用到所有页面。

// ==UserScript==
//@name    a_simple_test
//@include *
// ==/UserScript==
(function() {
    'use strict';
    alert('HelloWorld')
// Your code here...

})();

这样,在打开任意网站后,都会弹出一个 HelloWorld 的对话框。

4、熟悉 JavaScript 的语法

JavaScript 用法 | 菜鸟教程中了解JS的基本用法。

在此出现的问题:

Q.4:之前 HelloWorld 脚本中的alert('HelloWorld')末尾没有分号,这种写法是否合法?

是合法的!在 JavaScript 中,分号(;)是可选的,因为 JavaScript 有一个自动分号插入(Automatic Semicolon Insertion, ASI)的机制。在大多数情况下,即使你省略了分号,JavaScript 引擎也会根据上下文自动插入。

解释代码中的 alert('HelloWorld')

在以下代码中:

alert('HelloWorld')

即使没有写分号,JavaScript 引擎仍然能正确解析,因为:

  1. alert('HelloWorld') 是一个独立的语句。

  2. ASI 机制会在行末自动插入分号,等效于:

     alert('HelloWorld');
    

因此,这种省略分号的写法是合法的。

什么时候分号是必要的?

尽管 ASI 机制可以避免大部分语法错误,但在某些特定情况下,省略分号可能导致意外行为。例如:

情况 1:行首是 [( 的表达式

let a = 5
[1, 2, 3].forEach(x => console.log(x))

在这种情况下,JavaScript 会将 [1, 2, 3] 视为数组访问器,而不是独立的数组字面量。

解决方法:

let a = 5;
[1, 2, 3].forEach(x => console.log(x));

情况 2:返回值与表达式在不同行

function test() {
    return
   {
        value: 42
    }
}
console.log(test()) // undefined

在这种情况下,return 语句后自动插入了分号,因此实际返回的是 undefined

解决方法:

function test() {
    return {
       value: 42
    };
}

建议

  1. 始终使用分号:虽然 JavaScript 的 ASI 机制可以省略分号,但为了代码的可读性和避免潜在的错误,建议显式添加分号。
  2. 遵循团队规范:如果项目使用了某种代码风格(如 Airbnb 或 Standard),遵循其规范(例如 Standard JS 就不使用分号)。
  3. 工具辅助:使用代码检查工具(如 ESLint)和格式化工具(如 Prettier)来统一代码风格。

结论

在你的脚本中,省略 alert('HelloWorld') 的分号是合法的,且能正确运行。不过,养成显式添加分号的习惯会更安全、更规范。

5、(番外)为插件创作一个图标

使用 ChatGPT 绘制一个笔记本的图标,将其转换为64*64大小。

使用 SVG 在线编辑器 | 菜鸟工具 将其转化为矢量图形,并使其可以用代码表示出来。

发现 ChatGPT 绘制的图形不是纯色图形,会导致代码过长,故放弃使用 ChatGPT 绘制的图形,转而使用网络上公开的资源,使用 SVG 工具将其稍加修改。

接下来,将其转换为可以直接在@icon后使用的格式,即 Data URI 格式。使用在线工具在线SVG转DataURL - UU在线工具自动转化。

6、创建一个唤出笔记本的标识

我们需要一个拥有以下功能的标识:

  • 在浏览器页面的边框处,并始终位于页面最上方。
  • 点击后会唤出笔记本。
  • 拥有一些简单的动画效果。
  • 随页面滚动时不改变标识的位置。

首先要知道如何使用 JavaScript 创建并插入 HTML 元素。

查阅了HTML DOM insertAdjacentHTML() 方法 - 菜鸟教程HTML DOM querySelector() 方法 | 菜鸟教程

document.querySelector("body").insertAdjacentHTML("afterend", "<div><p>Hello world</p></div>");
//表示在<body>元素后插入<div>元素。先用querySelector()获取<body>元素的document,再用insertAdjacentHTML(,)插入元素。

现在我们要将这个元素置于我们希望它存在的位置,为此,我们需要了解如何利用脚本改变元素的 CSS 样式,以及如何书写 CSS。

设置位置查阅的资料:CSS position 属性 | 菜鸟教程CSS bottom 属性 | 菜鸟教程使用css,如何把div设置为圆形-百度经验CSS border 属性 | 菜鸟教程

    document.querySelector("body").insertAdjacentHTML("afterbegin",'<div id="nicoBt"><p>helloworld</></div>');
    //表示在<body>元素后插入<div>元素
    var nicoButton = document.getElementById("nicoBt");
    //var 表示定义变量,根据getElementById()利用id找到<div>元素
    var nicoButtonStyle = "\
    position: fixed;\
    right: 1%;\
    bottom:80%;\
    width: 44px;\
    height: 44px;\
    border: 2px solid #D3D3D3;\
    border-radius: 50%;\
    background: rgb(0, 127, 255);\
    z-index: 2147483647;\
    ";
    //第一句表示依照浏览器边框定位,right与bottom定义位置,width与height定义长宽,border用于定义边框,border-radius用于实现圆形效果,z-index定义了元素的图层高度,设置一个很大的数字可以保证其置于其他图像上方。
    //定义<div>元素的CSS样式,输入字符串时进行换行操作,在每行末尾加上\
    //末尾的;可加可不加
    nicoButton.setAttribute("style",nicoButtonStyle);
    //setAttribute(,)可以改变元素属性,用来改变nicoButton的CSS样式

现在我们把这个

元素改写为一个按钮,把图标插入进去。

var htmlNicoButton = '\
<div id="nicoBt">\
        <img src="图标URL">\
</div>\
';
document.querySelector("body").insertAdjacentHTML("afterbegin",htmlNicoButton);
//以下略

2024/12/9


    position: absolute;/*使用相对位置*/
    top: 50%;/*竖直方向上一半*/
    left: 50%;/*水平方向上一半*/
    transform: translate(-50%, -50%);/*x,y方向上均往回移动自身的一般*/

使用上面的方法把图像居中,参考:CSS3 transform 属性 | 菜鸟教程

发现存在<iframe>时会添加很多的标识,希望只在主页面添加,加上判断:

    if (window.self === window.top) {
        document.querySelector("body").insertAdjacentHTML("afterend",htmlNicoButton);
    }

参考了JavaScript 比较和逻辑运算符 | 菜鸟教程Window self 属性 | 菜鸟教程

下面我们要让鼠标悬停在标识上方时有一定的反馈效果,这里利用了addEventListener()设置两个效果,参考了HTML DOM addEventListener() 方法 | 菜鸟教程

    nicoButton.addEventListener("mouseover", function () { //光标移动到图标上,执行函数
        nicoButton.style.width = "55px";
        nicoButton.style.height = "55px";
        //大小改变
        nicoButton.style.cursor = "pointer";
        //鼠标光标从箭头改编为小手
    });

    nicoButton.addEventListener("mouseout", function () { //光标移开
        nicoButton.style.width = "48px";
        nicoButton.style.height = "48px";
        nicoButton.style.cursor = "";
        //变回
    });

7、创建一个容器

创建一个

作为我们的笔记本的容器:

    var nicoNoteDiv = document.createElement("div");//这里用document.creatElement()创建元素。ps.比最开始进步了
    nicoNoteDiv.setAttribute("id","nicoNoteDiv");

用点击事件呼出编辑框,并让图标不透明度降低(参考css中让元素消失的方法_transition visibility 无效-CSDN博客):

    nicoButton.addEventListener("click", function () {
     document.querySelector("html").appendChild(nicoNoteDiv);//用点击事件呼出编辑框,添加在 nicoBt 后面
     nicoButton.style.opacity="0.3"; //图标不透明度降低
    });
//这里用 elementA.appendChild(elemenetB):表明在A内最后添加B元素

下面我们来设计这个nicoNoteDiv除了最基本的长宽位置之外,我们希望它可以允许自己通过拖拽调节大小和位置(似乎略有些麻烦)。

2024/12/10


2024/12/11 去医院了,还被坑了,详情请见:只身陷入心理治疗骗局!!深扒“SCIO全科生物反馈测疗仪”与“心理AI智能检测”底裤。 - 知乎


①拖拽调节位置

为了使其可以调节自身位置和大小,我们应该从两方考虑:

  1. CSS 角度,在鼠标靠近
    边缘时,光标应该发生变化。
  2. JS 角度,根据鼠标光标的坐标改变
    的大小。

为了实现上述操作,我们考虑把存放笔记本主体的

和调整大小的
分开,外层仅仅实现拖拽功能,内层为主体。

Q.5:在查询资料时发现的() => {}是什么意思?

(e) => {} 是 JavaScript 中的箭头函数语法,是定义函数的一种简洁方式。它可以用来代替传统的 function 声明。

语法

(param1, param2, ...) => { // 函数体 }

分解理解 (e) => {}

  1. 箭头函数
    • => 是箭头函数的核心,表示这是一个函数。
  2. **参数 (e)**:
  • e 是箭头函数的参数。它通常代表事件对象,尤其是在事件处理程序中,比如 addEventListener
  1. **函数体 {}**:
  • {} 包含函数执行的代码。

完整例子

以下是一个常见的使用场景:

传统函数写法

document.addEventListener("click", function(e) {
    console.log("鼠标点击了页面", e);
});

箭头函数写法

document.addEventListener("click", (e) => {
    console.log("鼠标点击了页面", e);
});

简化规则

  1. 参数简写

    • 如果只有一个参数,可以省略括号 ()

      e => console.log(e);
      
    • 如果没有参数或有多个参数,则必须使用括号。

       () => console.log("无参数");
      (x, y) => console.log(x + y);
      
    
    
  2. 函数体简写

    • 如果函数体只有一行代码,且直接返回值,可以省略 {} 和 return
    x => x * x; // 返回 x 的平方
    

优势

  • 简洁:语法更短,易读。
  • 自动绑定上下文:箭头函数不会创建自己的 this,而是继承自外层作用域。

例子:箭头函数绑定上下文

function Example() {
    this.name = "箭头函数示例";
    document.addEventListener("click", () => {
       console.log(this.name); // 指向 Example 实例
    });
}
new Example();

对比传统函数

特性 箭头函数 普通函数
语法简洁
this 指向 继承外层作用域 动态绑定,取决于调用位置
使用场景 回调函数、简单逻辑 需要动态绑定 this 的复杂场景

箭头函数非常适合在事件处理、数组操作等回调中使用,但如果需要灵活绑定 this,普通函数可能更适合。

先实现较简单的拖拽:

首先我们应该想办法获取鼠标的光标坐标,可以利用addEventListener监听mousemove事件,再将生成的MouseEvent 对象传入参数,提取clientXclientY的信息。

关于这一过程的具体原理 Chat GPT 回答如下:

在以下代码中:

document.addEventListener('mousemove', (event) => { 
const { clientX, clientY } = event;
console.log(`鼠标X坐标: ${clientX}, 鼠标Y坐标: ${clientY}`);//console.log()用于把信息输出到工作台。${}表示在字符串中插入函数或变量。
});

事件参数 event 的捕捉和传递机制:

  1. 事件监听器的注册
    • document.addEventListener('mousemove', callback) 将事件监听器绑定到 document 上,用于监听 mousemove 事件。
    • 当用户移动鼠标时,浏览器会触发 mousemove 事件。
  2. 事件对象的生成
    • 浏览器在触发事件时,会生成一个 Event 对象。对于 mousemove 事件,生成的对象是一个 MouseEvent 实例,它继承了 Event 的所有属性和方法,同时还包含鼠标相关的属性,比如 clientXclientY
  3. 事件对象的传递
    • 当事件触发时,浏览器会将生成的 MouseEvent 对象作为参数传递给监听器中定义的回调函数 (event) => { ... }
  4. 解构赋值
    • 在回调函数中,通过 const { clientX, clientY } = event; 使用了解构赋值,从 event 对象中提取 clientXclientY 属性值,代表鼠标指针相对于视口左上角的 X 和 Y 坐标。
  5. 输出结果
    • 提取到的坐标值会被插入到字符串中,并通过 console.log 输出到控制台。

总结: 浏览器自动生成事件对象并将其作为参数传递给事件回调函数。你只需定义回调函数的参数名称(这里是 event),浏览器会负责传递这个对象。

MouseEvent实例中还有那些属性?

MouseEvent 是由浏览器生成的事件对象,专门用于表示与鼠标操作相关的事件(例如 clickmousemovemousedown 等)。它继承自 UIEvent,并进一步继承自 EventMouseEvent 实例包含许多有用的属性,用于描述鼠标事件的详细信息。

以下是 MouseEvent 的主要属性:

鼠标位置信息

  • **clientX**:鼠标指针相对于浏览器视口(窗口)左边缘的 X 坐标。
  • **clientY**:鼠标指针相对于浏览器视口(窗口)顶部的 Y 坐标。
  • **screenX**:鼠标指针相对于屏幕左边缘的 X 坐标。
  • **screenY**:鼠标指针相对于屏幕顶部的 Y 坐标。
  • **pageX**:鼠标指针相对于整个文档左边缘的 X 坐标(包括页面滚动距离)。
  • **pageY**:鼠标指针相对于整个文档顶部的 Y 坐标(包括页面滚动距离)。
  • **offsetX**:鼠标指针相对于事件目标元素左边缘的 X 坐标。
  • **offsetY**:鼠标指针相对于事件目标元素顶部的 Y 坐标。

鼠标按键信息

  • button
  • 触发事件的鼠标按键的代码:
    • 0:主按钮(通常是左键)
    • 1:中间按钮(滚轮)
    • 2:次按钮(通常是右键)
  • buttons
    • 一个按位标记的数字,表示当前按下的鼠标按钮:
      • 1:左键
      • 2:右键
      • 4:中键 (可以组合,如同时按下左键和右键时值为 3

键盘修饰键状态

这些属性表示事件发生时是否按下了修饰键:

  • **altKey**:true 表示 Alt 键被按下。
  • **ctrlKey**:true 表示 Ctrl 键被按下。
  • **metaKey**:true 表示 Meta 键被按下(Mac 上为 Command 键)。
  • **shiftKey**:true 表示 Shift 键被按下。

拖放和相关元素

  • relatedTarget
    • 对于某些事件(如 mouseovermouseout),表示鼠标从哪个元素移入/移出。
  • target
  • 事件的目标元素。
  • currentTarget
  • 当前正在处理事件的元素。

其他信息

  • **movementX**:自上一次 mousemove 事件以来,鼠标指针在 X 轴上的移动距离。
  • **movementY**:自上一次 mousemove 事件以来,鼠标指针在 Y 轴上的移动距离。
  • **detail**:事件的点击次数(适用于 clickdblclick 等事件)。

方法

  • getModifierState(key):检查指定的修饰键(如 AltShift)是否被按下。

继承自 UIEventEvent 的属性

  • **type**:事件类型(如 "click""mousemove")。
  • **target**:事件触发的目标元素。
  • **bubbles**:true 表示事件会冒泡。
  • **cancelable**:true 表示事件可以被取消。
  • **defaultPrevented**:true 表示默认操作已被阻止。
  • **timeStamp**:事件触发时的时间戳。

示例

document.addEventListener('mousemove', (event) => {
 console.log('clientX:', event.clientX);
  console.log('clientY:', event.clientY);
 console.log('screenX:', event.screenX);
  console.log('button:', event.button); 
  console.log('altKey pressed:', event.altKey);
});

这些属性可以帮助开发者获取鼠标的位置信息、按键状态和修饰键等详细信息,以便实现复杂的鼠标交互功能。

Q.6:什么是解构赋值?

const { clientX } = event;const clientX = event.clientX; 中,**clientX** 最终的效果是等价的,它们都会从 event 对象中提取 clientX 的值并赋值给一个同名变量。

但它们的语法和解构方式不同,具体区别如下:

解构赋值 (const { clientX } = event;)

这是 ES6 解构赋值 的语法,用于从对象中提取属性并赋值给同名变量。

const { clientX } = event;

等价于:

const clientX = event.clientX;

优点:

  • 可以同时提取多个属性:

     const { clientX, clientY } = event;
    

    上述写法相当于:

     const clientX = event.clientX;
    const clientY = event.clientY;
    
  • 语法更加简洁,适合处理多个属性时使用。

普通赋值 (const clientX = event.clientX;)

这是更传统的方式,逐个访问对象属性并赋值。

const clientX = event.clientX;

优点:

  • 更直观,适合只提取单个属性时。

总结

两种写法最终效果是等价的,区别在于语法形式和适用场景:

  1. 如果只需要提取单个属性,使用 const clientX = event.clientX;const { clientX } = event; 都可以。
  2. 如果需要提取多个属性,解构赋值更简洁且可读性更好。

例如:

// 解构赋值
const { clientX, clientY } = event;
// 传统方式
const clientX = event.clientX;
const clientY = event.clientY;

这里遇到了新的问题,最初设置

依靠的是百分比作为位置信息,在js操作中利用的是px,导致操作繁琐。

现在尝试把之前的代码进行一定的修改,在一番折腾后写出了以下代码:

    let offsetX = 0;
    let offsetY = 0;
    let nicoDivX =0;
    let nicoDivY =0;
    let ifDragging = false;
    //充当一个布尔变量,用于判断是否在抓取移动状态,let为局部变量
    nicoNoteDiv.addEventListener("mousedown",(e) => {//"mousedown"即监测鼠标点下的事件。
        ifDragging = true;
        nicoNoteDiv.style.cursor = "move";
        offsetX = e.clientX;
        offsetY = e.clientY;
        //记录初始鼠标坐标(相对于左上角)
        nicoDivY = parseInt(nicoNoteDiv.style.top);
        nicoDivX = parseInt(nicoNoteDiv.style.right);
        //获取div的位置parseInt()会将字符串转化为数字(忽略最后的非数字部分)
        e.preventDefault();
        //用来禁止鼠标拖拽时选中的功能
        nicoNoteDiv.addEventListener('mousemove',(e)=>{if (ifDragging){nicoDragging(e)}});
        //通过检测是否拖拽来启用拖拽函数
    });
    function nicoDragging(e) {//定义一个Dragging函数 语法:function 函数名(参数) {}
        const { clientX, clientY } = e;
        //获取当前鼠标坐标
        nicoNoteDiv.style.top = `${nicoDivY+clientY-offsetY}px`;
        //${}表示在字符串中插入函数或变量,但要确保使用反引号`Code`
        nicoNoteDiv.addEventListener("mouseup",() =>{ifDragging = false;});
        //松开鼠标,停止拖拽
    };

但是事情并没有那么简单,在松开鼠标后这个玩意还在动,原因如下:

JavaScript 中,监听器(事件监听器)只要没有被明确移除或停止,就会一直监听事件。

当你使用诸如 addEventListener 方法在 DOM 元素上绑定一个事件监听器时,这个监听器会一直处于活动状态,直到满足以下条件之一:

  1. 手动移除监听器
    可以使用 removeEventListener 方法来移除监听器。例如:
const button = document.getElementById('myButton');
function handleClick(event) {
    console.log('Button clicked!');
}
 
// 添加监听器
button.addEventListener('click', handleClick);
 
// 移除监听器
button.removeEventListener('click', handleClick);
  1. 元素被移除
    如果绑定了监听器的 DOM 元素被从 DOM 中移除,监听器也会随之停止。

  2. 一次性监听器
    如果在添加监听器时指定了 { once: true },监听器会在首次触发后自动移除。例如:

 button.addEventListener('click', handleClick, { once: true });

父监听器与子监听器的关系

如果你指的是在父元素上添加的事件监听器(比如通过事件冒泡机制监听子元素的事件),这种父监听器也会一直监听,除非明确移除。事件冒泡意味着子元素触发的事件会逐层向上传递到父元素,因此父监听器会接收到事件。比如:

const parent = document.getElementById('parent');

// 父元素监听器
parent.addEventListener('click', (event) => {
   console.log('Parent clicked!', event.target);
});

无论是直接点击父元素,还是点击子元素,父元素的监听器都会被触发,除非:

  • 你使用 stopPropagation() 来阻止冒泡;
  • 或者通过 removeEventListener 手动移除父监听器。

总结

  • 监听器会持续监听事件,直到手动移除或绑定的 DOM 元素被移除。
  • 如果没有停止或移除,父监听器会一直监听其范围内的事件冒泡。

也就是说,由于mousemovemousedown内导致mousedmove一直没法停止。把:

nicoNoteDiv.addEventListener('mousemove',(e)=>{if (ifDragging){nicoDragging(e)}});
 nicoNoteDiv.addEventListener("mouseup",() =>{ifDragging = false;});

插在外面就行了。

最终成品:

        let offsetX = 0;
        let offsetY = 0;
        let nicoDivX =0;
        let nicoDivY =0;
        let ifDragging = false;
        //充当一个布尔变量,用于判断是否在抓取移动状态,let为局部变量
        nicoNoteDiv.addEventListener("mousedown",(e) => {//"mousedown"即监测鼠标点下的事件。
            ifDragging = true;
            offsetX = e.clientX;
            offsetY = e.clientY;
            //记录初始鼠标坐标(相对于左上角)
            nicoDivY = parseInt(nicoNoteDiv.style.top);
            nicoDivX = parseInt(nicoNoteDiv.style.right);
            //获取div的位置parseInt()会将字符串转化为数字(忽略最后的非数字部分)
            e.preventDefault();
            //用来禁止鼠标拖拽时选中的功能
        });
        nicoNoteDiv.addEventListener('mousemove',(e)=>{
            if(ifDragging){
                const { clientX, clientY } = e;
                //获取当前鼠标坐标
                nicoNoteDiv.style.top = `${nicoDivY+clientY-offsetY}px`;
                nicoNoteDiv.style.right = `${nicoDivX-clientX+offsetX}px`;
                //${}表示在字符串中插入函数或变量,但要确保使用反引号`Code `
            }
         });
        nicoNoteDiv.addEventListener("mouseup",() =>{ifDragging = false;});
        //松开鼠标,停止拖拽

考虑到这个玩意可以应用到之前那个图标上,我们把它封装成一个函数。

由于光标太快会划出element,有时候mousedown事件会停止监听,改变策略把mousemovemouseup监听对象改为document,即: document.addEventListener()

这里有个新问题:

Q.7如何在拖拽的同时不触发点击?

方法一:通过 mousemove 判断是否发生拖拽

可以在 mousedownmousemove 中记录鼠标的移动距离,并在 mouseup 时判断是否是一次有效的拖拽操作。如果移动的距离超过一定阈值,就认为是拖拽而非点击。

let isDragging = false;

const element = document.querySelector("#draggable");

element.addEventListener("mousedown", (e) => {
 isDragging = false; // 重置拖拽状态
 const startX = e.clientX;
 const startY = e.clientY;

 const onMouseMove = (e) => {
     if (Math.abs(e.clientX - startX) > 5 || Math.abs(e.clientY - startY) > 5) {
         isDragging = true;
     }
 };

 const onMouseUp = () => {
     document.removeEventListener("mousemove", onMouseMove);
     document.removeEventListener("mouseup", onMouseUp);
 };

 document.addEventListener("mousemove", onMouseMove);
 document.addEventListener("mouseup", onMouseUp);
});

element.addEventListener("click", (e) => {
 if (isDragging) {
     e.preventDefault(); // 如果是拖拽,不触发点击
 }
});

方法二:使用 dragdragstart 事件

如果使用 HTML5 的拖放 API,可以利用 dragdragstart 来区分拖拽和点击。

let isDragging = false;

const element = document.querySelector("#draggable");

element.addEventListener("dragstart", () => {
 isDragging = true; // 标记为拖拽
});

element.addEventListener("click", (e) => {
 if (isDragging) {
     e.preventDefault(); // 阻止点击事件
     isDragging = false; // 重置状态
 }
});

方法三:直接在 click 中判断拖拽状态

如果你的拖拽实现中没有其他复杂的状态,也可以简单地在 click 事件中检查。

let isDragging = false;

element.addEventListener("mousedown", () => {
 isDragging = false;
});

element.addEventListener("mousemove", () => {
 isDragging = true; // 如果发生移动,标记为拖拽
});

element.addEventListener("mouseup", () => {
 setTimeout(() => (isDragging = false), 0); // 延迟重置状态
});

element.addEventListener("click", (e) => {
 if (isDragging) {
     e.preventDefault();
 }
});

方法四:CSS pointer-events 阻止点击

如果拖拽操作是通过 CSS 结合 JavaScript 实现,可以在拖拽时临时禁用 pointer-events

const element = document.querySelector("#draggable");

element.addEventListener("mousedown", () => {
 element.style.pointerEvents = "none"; // 禁止点击
});

element.addEventListener("mouseup", () => {
 setTimeout(() => {
     element.style.pointerEvents = "auto"; // 恢复点击
 }, 0);
});

总结

最推荐的方法是 方法一,结合鼠标的移动距离判断是否是拖拽,因为它适用于大多数拖拽实现场景,且与 HTML5 拖放 API 或 CSS 的依赖性较低。

这里采用方法四,而 mouseup 的监听对象一定为 document 才有效,最后将right改为更易操作的的left,封装后的函数如下:

    function letWeDrag(dragDiv){
        let offsetX = 0;
        let offsetY = 0;
        let nicoDivX =0;
        let nicoDivY =0;
        let ifDragging = false;
        //充当一个布尔变量,用于判断是否在抓取移动状态,let为局部变量
        dragDiv.addEventListener("mousedown",(e) => {//"mousedown"即监测鼠标点下的事件。
            ifDragging = true;
            offsetX = e.clientX;
            offsetY = e.clientY;
            //记录初始鼠标坐标(相对于左上角)
            nicoDivY = parseInt(dragDiv.style.top);
            nicoDivX = parseInt(dragDiv.style.left);
            //获取div的位置parseInt()会将字符串转化为数字(忽略最后的非数字部分)
            e.preventDefault();
            //用来禁止鼠标拖拽时选中的功能
        });
        document.addEventListener('mousemove',(e)=>{
            if(ifDragging){
                const { clientX, clientY } = e;
                //获取当前鼠标坐标
                dragDiv.style.top = `${nicoDivY+clientY-offsetY}px`;
                dragDiv.style.left = `${nicoDivX+clientX-offsetX}px`;
                //${}表示在字符串中插入函数或变量,但要确保使用反引号`Code`
                dragDiv.style.pointerEvents = "none";
                //禁止单击,防止拖拽同时点击。
            }
        });
        document.addEventListener("mouseup",() =>{
            ifDragging = false;
            dragDiv.style.pointerEvents = "auto"; // 恢复点击
        });
        //松开鼠标,停止拖拽
    }

由于将right换用left,为了使图标继续在右侧,我们应该获取屏幕的宽度。

Q.8:如何获取屏幕的宽度?

在 JavaScript 中,可以使用以下方法获取屏幕的 像素信息

1. 获取屏幕的宽度和高度(window.screen

通过 window.screen 对象,可以获取屏幕的物理分辨率:

console.log("屏幕宽度:" + window.screen.width + "px");
console.log("屏幕高度:" + window.screen.height + "px");
  • window.screen.width:屏幕的宽度(像素)。
  • window.screen.height:屏幕的高度(像素)。

2. 获取可用工作区域的尺寸

window.screen.availWidthwindow.screen.availHeight 用于获取屏幕除去任务栏或工具栏后的可用尺寸:

javascript复制代码console.log("可用宽度:" + window.screen.availWidth + "px");
console.log("可用高度:" + window.screen.availHeight + "px");

3. 获取浏览器窗口的尺寸

通过 window.innerWidthwindow.innerHeight 可以获取浏览器窗口的内容区域大小(不包含工具栏或边框):

javascript复制代码console.log("窗口宽度:" + window.innerWidth + "px");
console.log("窗口高度:" + window.innerHeight + "px");

4. 获取设备像素比(devicePixelRatio

window.devicePixelRatio 返回设备像素比(DPR),表示物理像素与 CSS 像素的比值:

console.log("设备像素比:" + window.devicePixelRatio);

例如:

  • 如果 devicePixelRatio = 2,表示 1 个 CSS 像素对应 2 个物理像素。
  • 通常高分辨率设备(如 Retina 屏幕)会有较高的 DPR 值。

5. 获取页面滚动的像素位置

通过 window.scrollXwindow.scrollY 可以获取页面的水平和垂直滚动位置:

console.log("水平滚动位置:" + window.scrollX + "px");
console.log("垂直滚动位置:" + window.scrollY + "px");

6. 获取元素的大小和位置

如果要获取某个 HTML 元素的宽度和高度,可以使用 getBoundingClientRect()

const element = document.querySelector("#myElement");
const rect = element.getBoundingClientRect();
console.log(`元素宽度:${rect.width}px, 高度:${rect.height}px`);
console.log(`元素位置:左=${rect.left}px, 上=${rect.top}px`);

总结

  • 屏幕尺寸:window.screen.widthwindow.screen.height
  • 可用区域:window.screen.availWidthwindow.screen.availHeight
  • 浏览器窗口:window.innerWidthwindow.innerHeight
  • DPR:window.devicePixelRatio
  • 滚动位置:window.scrollXwindow.scrollY

这里我们用到window.innerWidth就可以了。

这里我们还要保证窗口调节时,它的位置能一同调节,用以下函数,达到这一效果:

window.top.addEventListener('resize', () => {//监听顶层窗口调节大小事件
    const element = document.getElementById("nicoBt");
    const elementWidth = element.offsetWidth;
    const viewportWidth = window.innerWidth;
    element.style.left = (viewportWidth - elementWidth-10) + "px";
    nicoNoteDiv.style.top = `${parseInt(nicoButton.style.top)}px`;
    nicoNoteDiv.style.left = `${parseInt(nicoButton.style.left)-parseInt(nicoNoteDiv.style.width)}px`;
});

2024/12/12


②拖拽调节大小

只需要在CSS中添加:

resize: both;/*允许元素在x、y两个方向上调整大小*/
overflow: auto;/*显示滚动条*/

就可以实现简单的调整大小,参考css resize-CSDN博客

注意的是,能正常调整的条件是:CSS样式中,元素的位置描述使用 top 与 left。

但是我们要让它有最大最小的限制,思路是通过监听鼠标,通过JS来限制大小。

document.addEventListener("mouseup",()=>{
    if(parseInt(nicoNoteDiv.style.width)<200){ nicoNoteDiv.style.width="200px";};
    if(parseInt(nicoNoteDiv.style.height)<40){ nicoNoteDiv.style.height="40px";}
});

③组合起来!

由于这两者判定位置重合,因此我们应该使用一定的嵌套。

这时我们就要对 UI 进行一定的规划:也就是流出上方一条允许拖拽,其余部分无法拖拽。

让外层元素调节大小,内层元素负责拖动,这样一个东西就做好了!

8、引入 Vditor

JS 与 CSS 的引入

终于捣鼓完这个框框了,接下来我们要在这个容器中引入文本编辑器。

使用开源项目 Vditor 来作为文本编辑器 ,根据开发者文档尝试在 HTML 中引入 vditor 的 JS 与 CSS 。

<!-- ⚠️生产环境请指定版本号,如 https://unpkg.com/vditor@x.x.x/dist... -->
<link rel="stylesheet" href="https://unpkg.com/vditor/dist/index.css" />
<script src="https://unpkg.com/vditor/dist/index.min.js"></script>

把这行代码写入中:

    document.querySelector("head").insertAdjacentHTML("afterbegin",headVditorCode);
Q.9:之前讲过用@require加载脚本,这里可以那样做吗?

可以,且更推荐使用@require的方式,因为如果你在插入资源后立即使用 Vditor,可能会因为资源未加载完成而报错,并且动态插入 HTML 字符串不够直观,可能会增加代码阅读和维护的难度。

但是**@require只能用来加载 JS 而不能用于 CSS **使用GM_addStyle可以解决这一问题。

// ==UserScript==
// @name         Vditor Integration with CSS
// @namespace    http://tampermonkey.net/
// @version      1.0
// @description  Integrate Vditor
// @author       Your Name
// @match        *://*/*
// @require      https://cdn.jsdelivr.net/npm/vditor/dist/index.min.js
// @grant        GM_addStyle
// ==/UserScript==

// 使用 GM_addStyle 插入 CSS 样式
GM_addStyle(`
@import url('https://cdn.jsdelivr.net/npm/vditor/dist/index.css');
`);

如果你坚持使用动态插入方式,建议优化为分步加载,避免时序问题:

// 动态加载 CSS
var link = document.createElement('link');
link.rel = 'stylesheet';
link.href = 'https://unpkg.com/vditor/dist/index.css';
document.head.appendChild(link);

// 动态加载 JS
var script = document.createElement('script');
script.src = 'https://unpkg.com/vditor/dist/index.min.js';
script.onload = function () {
// 确保 Vditor 加载完成后再初始化
new Vditor('editor', {
  height: 300,
  placeholder: 'Write something...',
});
};
document.head.appendChild(script);

这种方式更易维护,且避免 HTML 字符串操作的潜在问题。

Vditor 的初始化

为了理解如何引入 Vditor ,我需要花一点时间来对文档进行理解:

首先,Vditor 定义了一个名为 Vditor 的类。

这个类包含若干属性,最主要的是 id 和 options。

我们首先要创建一个这个”类“的”实例“,可以理解为所谓”实例“就是一个这个”类“的变量。

创建实例的语法为: new 类名(参数1,参数2。。。)

在这个Vditor就应该写成:new Vditor(id,options,....)

这个id是一个字符串,而这个 options 里面包含了许多属性,我们把这些属性用大括号括起来。

另外 options 还有诸如 options.toolbar options.counter 的子属性他们的书写方式文档中也有写。

此外,我们这个类中还有一些”方法“methods供我们操作这些实例。

第一次尝试引入:

    const vditor = new Vditor("nicoVditor",{
        placeholder: 'Write something here...',
        toolbar: ['emoji', 'headings', 'bold', 'italic', 'strike', 'link', 'list'],
    })

显示 nicoVditor Not found ,推断是初始化代码在 nicoVditor 被添加之前就已经运行,通过调整代码逻辑,成功解决该问题。

再次运行显示:GET https://unpkg.com/vditor@3.10.8/dist/js/i18n/zh_CN.js net::ERR_ABORTED 404 (Not Found)

怀疑是脚本无权访问此文件,在元属性中添加// @require https://unpkg.com/vditor@3.10.8/dist/js/i18n/zh_CN.js成功运行。

接下来就是简单的调试一下!

2024/12/13


9、尝试与修复 CDN 被 CSP 拦截的问题(待解决)

发现无法在 Github 中初始化,询问AI后得到以下解答:

这个错误消息表示,浏览器拒绝加载样式表 https://cdn.jsdelivr.net/npm/vditor/dist/index.css,因为它违反了当前页面的内容安全策略(Content Security Policy,CSP)指令。具体来说,CSP 指令 style-src 'unsafe-inline' github.githubassets.com 限制了可以加载样式表的来源,而 jsdelivr.net 不在允许的来源列表中。

可能导致该错误的原因

  1. 严格的内容安全策略:GitHub 页面设置了严格的 CSP,限制了可以加载样式表的来源,以提高安全性。
  2. 外部样式表来源不被允许:当前 CSP 只允许从 github.githubassets.com 加载样式表,而不允许从 jsdelivr.net 加载。

搜索方案,发现某些项目可以绕过这一限制:guansss/webpack-monkey:一个 webpack 插件,用于使用现代工作流程开发您的用户脚本,具有 HMR、元生成等功能。

使用 webpack-monkey

用 npm 或 yarn(install 替换为 add) 均可。(超时挂梯子,给命令台走代理)

对该项目进行安装与初始化:
npm init -y
npm install webpack webpack-cli webpack-dev-server webpack-monkey  --save-dev
创建以下目录:
.
├── dist
│   └── hello.user.js
├── src
│   ├── index.js
│   └── meta.js
├── webpack.config.js
└── package.json
编写脚本:
  • src/index.js 中编写脚本逻辑(不包含元数据)
GM_log("Hello world!");

if (module.hot) {
  module.hot.monkeyReload();
}
  • src/meta.js 中定义脚本的元数据
module.exports = {
  name: "Hello world",
  version: "1.0.0",
  match: ["*://example.com/"],
};
  • webpack.config.js 中配置 webpack-monkey
const path = require("path")
const { monkey } = require("webpack-monkey")
//
module.exports = monkey({
  entry: './src/index.js',//输入文件
  output: {
    path: path.resolve(__dirname, "dist"),//输出目录
  },
})
配置构建脚本

package.json 中添加构建脚本,相当于添加了命令台指令:

{
  "scripts": {
    "dev": "webpack serve --mode development",//调试工具
    "build": "webpack --mode production"//生成工具
  }
}
生成用户脚本
npm build

会在 dist 中生成用户脚本。

尝试绕过CSP

webpack-monkey 的原理是:通过将需要外部加载的脚本与CSS样表与油猴脚本一起打包到一个文件,从而解决了 CSP 的问题。

首先,安装用于打包 CSS 与 JS 的 loader:

npm install css-loader style-loader --save-dev#用于加载CSS
npm install script-loader --save-dev#用于加载JS

在 Webpack 配置中,添加相关配置:

const path = require("path");
const { monkey } = require("webpack-monkey");
const TerserPlugin = require("terser-webpack-plugin"); // 引入 terser-webpack-plugin

module.exports = monkey({
  entry: './src/index.js',//输入文件
  output: {
    path: path.resolve(__dirname, "dist"),//输出目录
  },
  module: {
    rules: [
      {
        test: /\.css$/i, // 匹配所有 .css 文件
        use: ["style-loader", "css-loader"], // 使用 style-loader 和 css-loader 处理
      },
      {
        test: /external-script\.js$/,  // 匹配外部 JS 脚本
        use: "script-loader",  // 使用 script-loader 打包
      },
    ],
  },
});

将脚本和CSS下载到本地后,在 index.js 文件中引入本地的脚本:

import "./CSS/index.css";
import "./JS/index.min.js";

运行后生成了打包后的脚本,但无法运行,反思后认为原因如下:

webpack打包的js中又通过字符串操作引用了新的URL,而新的URL仍然不能绕过CSP。

并且不同的 JS 之间的相互依赖限制了运行顺序,运行顺序不当也会出现这种情况。

替代的解决方案

  1. 手动追踪所有的URL将他们修改并打包。缺点是耗时长且可维护性极低。(不可行)
  2. 使用拓展程序来手动禁用 CSP。

无法加载的其他未知原因与局限性分析

在某些页面不是因为 CSP 而无法初始化 Vditor (经过测试,所有搜索引擎的“搜索结果”页面都不能正常载入)原因暂时未知。

这类问题暴露出了油猴作为脚本运行器的局限性,或许我们可以开发浏览器插件,而不是油猴脚本。

总结就是,关于这个问题:

失敗した失敗した失敗した失敗した失敗した
失敗した失敗した失敗した失敗した失敗した
失敗した失敗した失敗した失敗した失敗した
失敗した失敗した失敗した失敗した失敗した
失敗した失敗した失敗した失敗した失敗した
あたしは失敗した失敗した失敗した失敗した
失敗した失敗した失敗した失敗した失敗した
失敗した失敗した失敗した失敗した失敗した

2024/12/13


10、储存编辑的文本

参考:HTML5 Web IndexedDB 数据库 | 菜鸟教程

Vditor 提供了把文件导出到本地的功能,但不能实现将笔记保存在当前网页的功能。

由于浏览器的网页存在严格的沙箱机制,因此我们无法利用浏览器脚本操作本地文件,自然也就不能利用脚本把文件保存在本地。

但是浏览器提供了 IndexedDB 这一机制,允许我们储大量结构化数据。它是异步的,可以存储较大的数据对象,适合需要存储大量数据或进行复杂查询的场景。因此我们甚至可以实现自动保存,历史版本管理的功能。

获取文本内容

Vditor 提供的工具中为我们提供了获取文本内容的 API:

方法 说明
exportJSON(markdown: string) 根据 Markdown 获取对应 JSON
getValue() 获取 Markdown 内容
getHTML() 获取 HTML 内容

创建 IndexedDB

const request = indexedDB.open("VditorHistoryDB", 1);//创建版本号为1的IndexedDB
let db;

初始化:

request.onupgradeneeded = e => {
    db = e.target.result;
    //表示成功打开的数据库实例。
    if (!db.objectStoreNames.contains("history")) {
        db.createObjectStore("history", { keyPath: "id" });
        //如果没有创建对象储存,则创建一个"history"对象储存。
        //而每一条主字段的标识为id,也就是我们可以在之后给每个字段添加id,作为标识。IndexedDB 会根据这个属性对记录进行索引。
    }
};

这里的onupgradeneeded代表数据库初次创建或版本号更新时执行函数。

**db.objectStoreNames**:一个类似数组的对象,列出了当前数据库中所有已存在的对象存储(表)。

而**contains("history")**:检查是否已存在名为 "history" 的对象存储。

如果数据库已经创建了,则:

request.onsuccess = e => {
    db = e.target.result;
};

onsuccess表示成功加载执行函数。

出现的问题:onsuccess无法正确触发

既不出发onsuccess也不触发onerror,只能触发onupgradeneeded,且控制台没有输出,原因暂时未知,所以无法修复。

这里采用替代方案每次进入都改变版本号来触发onupgradeneeded

将内容保存至数据库

这里使用数据库实例的 transaction() 方法创建一个事务:

const transaction = db.transaction(storeNames, mode);

**storeNames**:

  • 可以是一个字符串(单个对象存储的名称)或数组(多个对象存储的名称)。
  • 决定事务可以操作哪些对象存储。例如,"history" 表示事务将操作名为 "history" 的存储。

**mode**:

  • 事务的模式,定义了可以执行的操作:
    • "readonly":只读事务,只允许读取数据。
    • "readwrite":读写事务,允许读取和修改数据。

通过 transaction.objectStore() 获取特定的存储,操作其中的数据。

function saveContent() {
    const content = nicoVditor.getValue();//获取 Markdown 内容
    const timestamp = Date.now(); // 时间戳作为 ID
    const transaction = db.transaction("history", "readwrite");//创建一个操作 "history" 存储的读写事务。
    const store = transaction.objectStore("history");// 获取 "history" 对象存储
    store.put({ id: timestamp, content: content, savedAt: new Date().toLocaleString() });
//向"history" 存储中放入一个id为时间戳,内容为 Markdown 内容,保存的时间为当前时间的内容。
//savedAt 是一个字段,用于记录保存的时间。而new Date().toLocaleString() 是 JavaScript 的方法,返回当前时间的本地化格式(例如 2024/12/15 10:30:45)
    transaction.oncomplete = () => {
        alert("内容已保存!");
    };
    transaction.onerror = (event) => {
        console.error("保存失败", event.target.error);
    };
}

最后在工具栏插入一个按钮:

{
 name: 'nicoSave',
 tipPosition: 's',
 tip: '保存到浏览器',
 className: 'right',
 icon: ``,
 click () {saveContent()},}

出现的问题:getValue()未定义(已解决)

原因是无法引入method脚本文件,引入后问题依旧存在,替代方案:

document.querySelector("#nicoVditor > div.vditor-content > div.vditor-ir").innerHTML;//获取 HTML 内容

出错原因:在 saveContent() 函数中 Vditor 未被定义

解决方法:将 Vditor 作为参数传入函数中

返回历史版本

    function showHistory(ifFirst) {
        const transaction = db.transaction("history", "readonly");//创建读取事务的实例
        const store = transaction.objectStore("history");//获取对象储存的实例
        const request = store.getAll(); 
        request.onsuccess = (event) => { 
            const history = event.target.result; // 确保在请求成功后访问 result 属性 
            };
        history.sort((a, b) => b.id - a.id);// 按 ID(即时间戳)降序排序
        if(ifFirst){document.querySelector("#nicoVditor > div.vditor-content > div.vditor-ir").innerHTML = history[0].content;ifFirst=true;}
        else{return history;}
    }

这里直接使用修改.innerHTML导致返回保存后无法被继续编辑,改用setValue()重构代码

点击之后呼出弹窗

    function showHistoryDialog(history) {
        // 如果已有弹窗,先移除
        //const existingDialog = document.querySelector("#history-dialog");
        //if (existingDialog) existingDialog.remove();

        // 创建弹窗容器
        const dialog = document.createElement("div");
        dialog.id = "history-dialog";
        dialog.style.position = "fixed";
        dialog.style.top = "50%";
        dialog.style.left = "50%";
        dialog.style.transform = "translate(-50%, -50%)";
        dialog.style.background = "#fff";
        dialog.style.border = "1px solid #ddd";
        dialog.style.padding = "15px";
        dialog.style.boxShadow = "0px 2px 10px rgba(0, 0, 0, 0.3)";
        dialog.style.zIndex = "1000";
        dialog.style.maxHeight = "300px";
        dialog.style.overflowY = "auto";

        // 弹窗标题
        const title = document.createElement("h3");
        title.innerText = "历史记录";
        title.style.marginTop = "0";
        dialog.appendChild(title);

        // 遍历历史记录,添加按钮
        history.forEach((item, index) => {
            const button = document.createElement("button");
            button.innerText = `${index + 1}. ${item.savedAt}`;
            button.style.display = "block";
            button.style.marginBottom = "10px";
            button.style.width = "100%";

            // 点击按钮时写入内容
            button.onclick = () => {
                document.querySelector("#nicoVditor > div.vditor-content > div.vditor-ir").innerHTML = item.content;
                console.log(`已恢复历史记录: ${item.savedAt}`);
                dialog.remove(); // 关闭弹窗
            };

            dialog.appendChild(button);
        });

        // 关闭按钮
        const closeButton = document.createElement("button");
        closeButton.innerText = "关闭";
        closeButton.style.marginTop = "10px";
        closeButton.style.width = "100%";
        closeButton.onclick = () => dialog.remove();
        dialog.appendChild(closeButton);

        // 将弹窗插入到页面
        document.body.appendChild(dialog);
    }

但是因为 showHistory 函数是异步(允许它之后的代码先运行)的,而你在调用 showHistory 函数时期望立即获得结果。你需要确保在 request.onsuccess 事件处理程序中处理结果。

Q10.异步执行的函数如何获取返回值?

在 JavaScript 中,异步函数的返回值无法直接通过同步方式获取,因为异步操作(如网络请求、定时器等)需要时间完成。因此,需要通过以下几种方法来获取异步函数的返回值:

  1. Promisethen()

当异步函数返回一个 Promise 对象时,可以通过 .then() 方法来处理返回值。

示例代码

function asyncFunction() {
    return new Promise((resolve) => {
        setTimeout(() => {
            resolve("异步返回值");
        }, 1000); // 模拟异步操作,1秒后返回
    });
}

// 使用 then() 获取返回值
asyncFunction().then((result) => {
    console.log("获取到的返回值:", result);
});
  1. async/await

async/await 是对 Promise 的语法糖,使得异步代码更像同步代码,更易于理解和编写。

示例代码

async function asyncFunction() {
    return new Promise((resolve) => {
        setTimeout(() => {
            resolve("异步返回值");
        }, 1000); // 模拟异步操作
    });
}

// 使用 async/await 获取返回值
async function getValue() {
    const result = await asyncFunction();
    console.log("获取到的返回值:", result);
}

getValue();

注意:

  • await 只能在 async 函数 中使用。
  • await 会暂停当前 async 函数的执行,直到异步操作完成。
  1. 回调函数

将一个回调函数作为参数传入异步函数,在异步操作完成后调用该回调函数并传入返回值。

示例代码

function asyncFunction(callback) {
    setTimeout(() => {
        const value = "异步返回值";
        callback(value); // 调用回调函数,传入返回值
    }, 1000);
}

// 使用回调函数获取返回值
asyncFunction((result) => {
    console.log("获取到的返回值:", result);
});
  1. 结合 async/awaitPromise 的例子

当你调用一个异步函数,返回的是 Promise,你可以用 async/await 来等待它的完成。

示例代码

function fetchData() {
    return new Promise((resolve) => {
        setTimeout(() => resolve("返回数据成功"), 1000);
    });
}

async function getData() {
    const result = await fetchData();
    console.log("最终获取的数据:", result);
}

getData();
  1. 立即执行异步函数 (IIFE)

有时你需要在代码块内立即调用 async 函数,可以使用 **立即执行函数表达式 (IIFE)**。

示例代码

(async () => {
    const result = await asyncFunction();
    console.log("立即执行异步函数的返回值:", result);
})();

删除历史记录

使用 delate 方法删除历史记录:

const request = indexedDB.open("VditorHistoryDB", dbVersion);
request.onsuccess = (event) => {
    const db = event.target.result;
    const transaction = db.transaction("history", "readwrite");
    const store = transaction.objectStore("history");
    const deleteRequest = store.delete(recordId); // 替换 recordId 为你要删除的记录的 ID

    deleteRequest.onsuccess = () => {
        console.log("记录已删除");
    };

    deleteRequest.onerror = (event) => {
        console.error("删除记录失败", event.target.error);
    };
};

2024/12/19


三、完成

大概耗时十天时间,我们从无到有完成了这个脚本的编写。

事实上,我认为这次尝试,是利用 AI 进行学习的一种方式。

过去人们大部分情况下只能从前到后,一步步到达想要的结果,但是 AI 让我们可以从后向前,并且在这个过程中一点点弄清楚这个东西的原理,这一点是以往人所达不到的。

最后我希望我自己可以继续维护这个脚本,不断为其添加新的功能。


I am ordinary yet unique.