上面讲过,针对数据层的修改叫做 op,而多个 op 组合在一起叫 Transation。要做 undo 时就简单了,undo/redo 本身就是一个记录栈,每次把操作往栈里放,当用户 ctrl+z 撤销操作时,则从栈顶取出并执行就可。 执行一次 op 的过程分成几步:
创建 Transation t 对象 把 op 添加到 t.operations 数组中 算出当前 op 的反操作,添加到 t.invertedOperations 数组中,供后续撤回使用
以下就是相关代码
1
不过这里怎么取 op 的反操作有意思,比如当用户输入完文字后,会把当前用户输入的值当成 set 操作的参数来执行修改数据模型的值 ,同时也会把当前内存中的 block text 算出一个反操作(因为此时数据模型中的值还没有更新),并记录起来。所以反操作是在生成操作时就算出来了,而不是等着用户触发撤回再算,因为执行操作前的状态就是执行完操作后再撤回的状态,这时算就有足够的信息。
如一个 block text 的原先值为 “hello”,当用户输入了一个空格,则新的值为 “hello “,会得到以下两个操作:
上面代码就完成了三步,分别标识出来了,接下来讲下更具体的。 选区 刚讲过文字选区是浏览器 contenteditable 提供的能力,用户鼠标选中区间后,通过浏览器提供的 window.getSelection() API 可以获取出用户选择了哪些文字。 区间查找 此时需要映射到文字存储区间,常规可能是把选区映射到文字数组下标,然后按文字下标从左往右找,效率低,查找复杂度为 o(n) 的。但这样显然不高效,上面渲染成 html 时,会在 span 节点带个 data-token-index 属性标识数据存储区间的数据下标位置。有了这个位置就好对应了,整个映射流程是 然而搞笑的事,他并没有这样做。这个 data-token-index 是用于其它定位,而不是用于这里的区间查找,区间查找查找复杂度还是 o(n)。 他的做法是把文字选择的下标位置记下来,然后去数组区间循环遍历一个一个按 o(n) 的方式找。 const We = function(e, t, n) { const r = we(e), o = [], a = [], s = []; let l = 0; for (const c of r) { const e = G(c), r = le(c), d = i.a.toArray(e), u = l, p = l + d.length; if (p <= t) o.push(c); else if (u >= n) a.push(c); else if (u >= t && p <= n) s.push(c); // 整个区间命中 else if (u <= t && p >= n) { // 右半区间命中 const e = t - u, i = e + n - t, l = d.slice(0, e), c = d.slice(e, i), p = d.slice(i); l.length > 0 && o.push(_e(l.join(""), r)), p.length > 0 && a.push(_e(p.join(""), r)), c.length > 0 && s.push(_e(c.join(""), r)) } else if (u >= t && u < n) { // 左半区间命中 const e = n - u, t = d.slice(0, e), o = d.slice(e); o.length > 0 && a.push(_e(o.join(""), r)), t.length > 0 && s.push(_e(t.join(""), r)) } else if (u < t && p > t) { const e = t - u, n = d.slice(0, e), i = d.slice(e); n.length > 0 && o.push(_e(n.join(""), r)), i.length > 0 && s.push(_e(i.join(""), r)) } l = p } return { tokensBeforeRange: He(o), tokensInsideRange: He(s), tokensAfterRange: He(a) } }
// 2. 通过 markdown-it 渲染成 html,变设置到剪切版的 text/html const r = K(Wt({}, n, { markdown: p })); t.clipboardData.setData("text/html", en(e.device.isWindows, r))
上图代码先把选区所先的 block 及子孙节点都分别转成 markdown 格式,每种 block type 都有对应用的 markdown 转换器进行转换,转换后再通过 markdown-it 生成 html ,最后把 html 设置到剪切板中。 粘帖 粘帖分为内部与外部两种数据来源,内部数据源是指在 notion 文章内的复制粘帖;外部数据源是指从其它系统,如网页、word 等工具。粘帖分成几步:
数据获取:获取剪切版里数据 解析数据:根据数据类型不一样,使用不同的数据解析器来解析数据 数据应用:把解析出来的数据生成 notion 的 op ,通过执行完这些 op ,达到修改数据的目的
内部数据源 内部数据源的数据来源来内部系统复制,所以数据源是自己规定的,获取与解析都简单。 上面代码就是从剪切版里拿到内部复制时存到剪切版里的 json 数据,这里直接 json.parse 解析,解析完后把每个 block 都分别生成增加 block 的 op 执行就可以了。 外部数据源 外部复制分为两种来源,一种 office 与 非 office,非 office 是指网页等这类。 剪切版里的数据本来就有 html 格式的,html 会先渲染到离屏的 dom 对象中,notion 会分别递归迭代并解析这些 html 的节点,然后通过遍历这棵 dom tree,把 dom node 转成 notion block 节点的 op 操作。
1 2 3 4 5 6 7
if (a && a instanceof u.Element) { const t = a.tagName.toLowerCase(); // html h1 标签 if ("h1" === t) return [Ve({actor: n,parentId: r, parentTable: o, spaceId: i, node: a,...)]; // html h2 标签 if ("h2" === t)
…
1 2 3 4 5 6 7 8 9 10
if ("details" === t) return [Je({actor: n,parentId: r, parentTable: o, spaceId: i, node: a,...)]; // 表格 if ("table" === t) { const e = [], t = Array.from(a.querySelectorAll("tr")).filter(e => e.closest("table") === a); for (const n of t) { const t = [], r = Array.from(n.querySelectorAll("td, th")).filter(e => e.closest("tr") === n); for (const e of r) { const n = (e.textContent || "").trim(); t.push({ text: n, textValue: Re({ node: e, window: u, stripText: !1 }) })
}
1
e.push(t)
}
1
return 0 === s.a.flattenDeep(e).length ? [] : [Ne({actor: n,parentId: r, parentTable: o, spaceId: i, node: a,...)];
}
1 2 3
// div if ("div" === t && a.hasAttribute(_e[l.a.columnList])) return [Qe({actor: n,parentId: r, parentTable: o, spaceId: i, node: a,...)];
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17
上面就是每个 dom node 对 notion op 的解析流程,根据 node tag 类型有不同的 block 解析器。 const {actor: t, parentId: n, parentTable: r, spaceId: o, node: i, type: a, allOperations: s, window: l, ignoreBlockCount: c, stripText: u, randomID: p} = e, // 创建一个新的 block id h = p(), m = {id: h, version: 0, type: a, // dom 节点的值当成 block title 属性 properties: { title: Re({ node: i, window: l, stripText: u }) }, parent_id: n, parent_table: r, space_id: o, created_by_table: t.table, created_by_id: t.value.id, created_time: Date.now(), last_edited_by_table: t.table, last_edited_by_id: t.value.id, last_edited_time: Date.now(), alive: !0, ignore_block_count: !!c || void 0 }; // 给新的 block 设置值,生成新 op return s.push({ pointer: { table: d.b, id: h, spaceId: o }, command: "set",path: [],args: m }), h }
上面代码为其中一个 div 节点转 op 的过程,op 是创建一个 block,dom 里面的值会当成 block 的参数。 office 原理都一致,只是解析格式不一样,就不细看了。