|
1 | 1 | import { Node, Attribute } from '@core/index.js'; |
2 | 2 | import { Plugin, PluginKey } from 'prosemirror-state'; |
3 | 3 | import { Decoration, DecorationSet } from 'prosemirror-view'; |
| 4 | +import { ReplaceStep, ReplaceAroundStep, StepMap } from 'prosemirror-transform'; |
| 5 | +import { DOMSerializer } from 'prosemirror-model'; |
4 | 6 |
|
5 | 7 | export const TabNode = Node.create({ |
6 | 8 | name: 'tab', |
@@ -44,73 +46,250 @@ export const TabNode = Node.create({ |
44 | 46 | }, |
45 | 47 |
|
46 | 48 | addPmPlugins() { |
47 | | - const { view } = this.editor; |
| 49 | + const { view, schema } = this.editor; |
| 50 | + const domSerializer = DOMSerializer.fromSchema(schema); |
| 51 | + |
48 | 52 | const tabPlugin = new Plugin({ |
49 | 53 | name: 'tabPlugin', |
50 | 54 | key: new PluginKey('tabPlugin'), |
51 | 55 | state: { |
52 | | - init(_, state) { |
53 | | - let decorations = getTabDecorations(state, view); |
54 | | - return DecorationSet.create(state.doc, decorations); |
| 56 | + init() { |
| 57 | + return { decorations: false }; |
55 | 58 | }, |
| 59 | + apply(tr, { decorations }, _oldState, newState) { |
| 60 | + if (!decorations) { |
| 61 | + decorations = DecorationSet.create( |
| 62 | + newState.doc, |
| 63 | + getTabDecorations(newState.doc, StepMap.empty, view, domSerializer), |
| 64 | + ); |
| 65 | + } |
56 | 66 |
|
57 | | - apply(tr, oldDecorationSet, oldState, newState) { |
58 | | - if (!tr.docChanged) return oldDecorationSet; |
59 | | - const decorations = getTabDecorations(newState, view); |
60 | | - return DecorationSet.create(newState.doc, decorations); |
| 67 | + if (!tr.docChanged) { |
| 68 | + return { decorations }; |
| 69 | + } |
| 70 | + decorations = decorations.map(tr.mapping, tr.doc); |
| 71 | + |
| 72 | + let rangesToRecalculate = []; |
| 73 | + tr.steps.forEach((step, index) => { |
| 74 | + const stepMap = step.getMap(); |
| 75 | + if (step instanceof ReplaceStep || step instanceof ReplaceAroundStep) { |
| 76 | + const $from = tr.docs[index].resolve(step.from); |
| 77 | + const $to = tr.docs[index].resolve(step.to); |
| 78 | + const start = $from.start(Math.min($from.depth, 1)); // start of node at level 1 |
| 79 | + const end = $to.end(Math.min($to.depth, 1)); // end of node at level 1 |
| 80 | + let addRange = false; |
| 81 | + tr.docs[index].nodesBetween(start, end, (node) => { |
| 82 | + if (node.type.name === 'tab') { |
| 83 | + // Node contains or contained a tab |
| 84 | + addRange = true; |
| 85 | + } |
| 86 | + }); |
| 87 | + if (!addRange && step.slice?.content) { |
| 88 | + step.slice.content.descendants((node) => { |
| 89 | + if (node.type.name === 'tab') { |
| 90 | + // A tab was added. |
| 91 | + addRange = true; |
| 92 | + } |
| 93 | + }); |
| 94 | + } |
| 95 | + if (addRange) { |
| 96 | + rangesToRecalculate.push([start, end]); |
| 97 | + } |
| 98 | + } |
| 99 | + rangesToRecalculate = rangesToRecalculate.map(([from, to]) => { |
| 100 | + const mappedFrom = stepMap.map(from, -1); |
| 101 | + const mappedTo = stepMap.map(to, 1); |
| 102 | + return [mappedFrom, mappedTo]; |
| 103 | + }); |
| 104 | + }); |
| 105 | + rangesToRecalculate.forEach(([start, end]) => { |
| 106 | + const oldDecorations = decorations.find(start, end); |
| 107 | + decorations = decorations.remove(oldDecorations); |
| 108 | + const invertMapping = tr.mapping.invert(); |
| 109 | + const newDecorations = getTabDecorations(newState.doc, invertMapping, view, domSerializer, start, end); |
| 110 | + decorations = decorations.add(newState.doc, newDecorations); |
| 111 | + }); |
| 112 | + return { decorations }; |
61 | 113 | }, |
62 | 114 | }, |
63 | 115 | props: { |
64 | 116 | decorations(state) { |
65 | | - return this.getState(state); |
| 117 | + return this.getState(state).decorations; |
66 | 118 | }, |
67 | 119 | }, |
68 | 120 | }); |
69 | 121 | return [tabPlugin]; |
70 | 122 | }, |
71 | 123 | }); |
72 | 124 |
|
73 | | -const tabWidthPx = 48; |
| 125 | +const defaultTabDistance = 48; |
| 126 | +const defaultLineLength = 816; |
74 | 127 |
|
75 | | -const getTabDecorations = (state, view) => { |
| 128 | +const getTabDecorations = (doc, invertMapping, view, domSerializer, from = 0, to = null) => { |
| 129 | + // TODO: Render "bar". |
| 130 | + if (!to) { |
| 131 | + to = doc.content.size; |
| 132 | + } |
| 133 | + const nodeWidthCache = {}; |
76 | 134 | let decorations = []; |
77 | | - state.doc.descendants((node, pos) => { |
| 135 | + doc.nodesBetween(from, to, (node, pos, parent) => { |
78 | 136 | if (node.type.name === 'tab') { |
79 | | - let $pos = state.doc.resolve(pos); |
80 | | - const prevNodeSize = $pos.nodeBefore?.nodeSize || 0; |
81 | | - |
82 | | - let textWidth = 0; |
83 | | - |
84 | | - try { |
85 | | - state.doc.nodesBetween(pos - prevNodeSize - 1, pos - 1, (node, nodePos) => { |
86 | | - if (node.isText && node.textContent !== ' ') { |
87 | | - const textWidthForNode = calcTextWidth(view, nodePos); |
88 | | - textWidth += textWidthForNode; |
| 137 | + let extraStyles = ''; |
| 138 | + const $pos = doc.resolve(pos); |
| 139 | + const tabIndex = $pos.index($pos.depth); |
| 140 | + const fistlineIndent = parent.attrs?.indent?.firstLine || 0; |
| 141 | + const currentWidth = |
| 142 | + calcChildNodesWidth( |
| 143 | + parent, |
| 144 | + pos - $pos.parentOffset, |
| 145 | + 0, |
| 146 | + tabIndex, |
| 147 | + domSerializer, |
| 148 | + view, |
| 149 | + invertMapping, |
| 150 | + nodeWidthCache, |
| 151 | + ) + fistlineIndent; |
| 152 | + let tabWidth; |
| 153 | + if ($pos.depth === 1 && parent.attrs.tabStops && parent.attrs.tabStops.length > 0) { |
| 154 | + const tabStop = parent.attrs.tabStops.find((tabStop) => tabStop.pos > currentWidth && tabStop.val !== 'clear'); |
| 155 | + if (tabStop) { |
| 156 | + tabWidth = tabStop.pos - currentWidth; |
| 157 | + if (['end', 'center'].includes(tabStop.val)) { |
| 158 | + let nextTabIndex = tabIndex + 1; |
| 159 | + while (nextTabIndex < parent.childCount && parent.child(nextTabIndex).type.name !== 'tab') { |
| 160 | + nextTabIndex++; |
| 161 | + } |
| 162 | + const tabSectionWidth = calcChildNodesWidth( |
| 163 | + parent, |
| 164 | + pos - $pos.parentOffset, |
| 165 | + tabIndex, |
| 166 | + nextTabIndex, |
| 167 | + domSerializer, |
| 168 | + view, |
| 169 | + invertMapping, |
| 170 | + nodeWidthCache, |
| 171 | + ); |
| 172 | + tabWidth -= tabStop.val === 'end' ? tabSectionWidth : tabSectionWidth / 2; |
| 173 | + } else if (['decimal', 'num'].includes(tabStop.val)) { |
| 174 | + const breakChar = '.'; // TODO: The break character should likely be document language dependent. |
| 175 | + let nodeIndex = tabIndex + 1; |
| 176 | + let integralWidth = 0; |
| 177 | + let nodePos = pos - $pos.parentOffset; |
| 178 | + while (nodeIndex < parent.childCount) { |
| 179 | + const node = parent.child(nodeIndex); |
| 180 | + if (node.type.name === 'tab') { |
| 181 | + break; |
| 182 | + } |
| 183 | + const oldPos = invertMapping.map(nodePos); |
| 184 | + if (node.type.name === 'text' && node.text.includes(breakChar)) { |
| 185 | + // Only include text before the break character |
| 186 | + const modifiedNode = node.cut(0, node.text.indexOf(breakChar)); |
| 187 | + integralWidth += calcNodeWidth(domSerializer, modifiedNode, view, oldPos); |
| 188 | + break; |
| 189 | + } |
| 190 | + integralWidth += calcNodeWidth(domSerializer, node, view, oldPos); |
| 191 | + nodeWidthCache[nodePos] = integralWidth; |
| 192 | + nodePos += node.nodeSize; |
| 193 | + nodeIndex += 1; |
| 194 | + } |
| 195 | + tabWidth -= integralWidth; |
| 196 | + } |
| 197 | + if (tabStop.leader) { |
| 198 | + // TODO: The following styles will likely not correspond 1:1 to the original. Adjust as needed. |
| 199 | + if (tabStop.leader === 'dot') { |
| 200 | + extraStyles += `border-bottom: 1px dotted black;`; |
| 201 | + } else if (tabStop.leader === 'heavy') { |
| 202 | + extraStyles += `border-bottom: 2px solid black;`; |
| 203 | + } else if (tabStop.leader === 'hyphen') { |
| 204 | + extraStyles += `border-bottom: 1px solid black;`; |
| 205 | + } else if (tabStop.leader === 'middleDot') { |
| 206 | + extraStyles += `border-bottom: 1px dotted black; margin-bottom: 2px;`; |
| 207 | + } else if (tabStop.leader === 'underscore') { |
| 208 | + extraStyles += `border-bottom: 1px solid black;`; |
| 209 | + } |
89 | 210 | } |
90 | | - }); |
91 | | - } catch { |
92 | | - return; |
| 211 | + } |
| 212 | + } |
| 213 | + |
| 214 | + if (!tabWidth || tabWidth < 1) { |
| 215 | + tabWidth = defaultTabDistance - ((currentWidth % defaultLineLength) % defaultTabDistance); |
| 216 | + if (tabWidth === 0) { |
| 217 | + tabWidth = defaultTabDistance; |
| 218 | + } |
93 | 219 | } |
94 | 220 |
|
95 | | - const tabWidth = $pos.nodeBefore?.type.name === 'tab' ? tabWidthPx : tabWidthPx - (textWidth % tabWidthPx); |
| 221 | + nodeWidthCache[pos] = tabWidth; // Update width with final tab width - important for subsequent tabs. |
| 222 | + |
96 | 223 | const tabHeight = calcTabHeight($pos); |
97 | 224 |
|
98 | 225 | decorations.push( |
99 | | - Decoration.node(pos, pos + node.nodeSize, { style: `width: ${tabWidth}px; height: ${tabHeight};` }), |
| 226 | + Decoration.node(pos, pos + node.nodeSize, { |
| 227 | + style: `width: ${tabWidth}px; height: ${tabHeight};${extraStyles}`, |
| 228 | + }), |
100 | 229 | ); |
101 | 230 | } |
102 | 231 | }); |
103 | 232 | return decorations; |
104 | 233 | }; |
105 | 234 |
|
106 | | -function calcTextWidth(view, pos) { |
107 | | - const domNode = view.nodeDOM(pos); |
108 | | - if (domNode) { |
109 | | - const range = document.createRange(); |
110 | | - range.selectNodeContents(domNode); |
111 | | - return range.getBoundingClientRect().width; |
| 235 | +function calcNodeWidth(domSerializer, node, view, oldPos) { |
| 236 | + // Create dom node of node. Then calculate width. |
| 237 | + const oldDomNode = view.nodeDOM(oldPos); |
| 238 | + const styleReference = oldDomNode ? (oldDomNode.nodeName === '#text' ? oldDomNode.parentNode : oldDomNode) : view.dom; |
| 239 | + const temp = document.createElement('div'); |
| 240 | + const style = window.getComputedStyle(styleReference); |
| 241 | + // Copy relevant styles |
| 242 | + temp.style.cssText = ` |
| 243 | + position: absolute; |
| 244 | + top: -9999px; |
| 245 | + left: -9999px; |
| 246 | + white-space: nowrap; |
| 247 | + font-family: ${style.fontFamily}; |
| 248 | + font-size: ${style.fontSize}; |
| 249 | + font-weight: ${style.fontWeight}; |
| 250 | + font-style: ${style.fontStyle}; |
| 251 | + letter-spacing: ${style.letterSpacing}; |
| 252 | + word-spacing: ${style.wordSpacing}; |
| 253 | + text-transform: ${style.textTransform}; |
| 254 | + display: inline-block; |
| 255 | + `; |
| 256 | + |
| 257 | + const domNode = domSerializer.serializeNode(node); |
| 258 | + |
| 259 | + temp.appendChild(domNode); |
| 260 | + document.body.appendChild(temp); |
| 261 | + |
| 262 | + const width = temp.offsetWidth; |
| 263 | + document.body.removeChild(temp); |
| 264 | + |
| 265 | + return width; |
| 266 | +} |
| 267 | + |
| 268 | +function calcChildNodesWidth( |
| 269 | + parent, |
| 270 | + parentPos, |
| 271 | + startIndex, |
| 272 | + endIndex, |
| 273 | + domSerializer, |
| 274 | + view, |
| 275 | + invertMapping, |
| 276 | + nodeWidthCache, |
| 277 | +) { |
| 278 | + let pos = parentPos; |
| 279 | + let width = 0; |
| 280 | + for (let i = 0; i < endIndex; i++) { |
| 281 | + const node = parent.child(i); |
| 282 | + if (i >= startIndex) { |
| 283 | + if (!nodeWidthCache[pos]) { |
| 284 | + nodeWidthCache[pos] = calcNodeWidth(domSerializer, node, view, invertMapping.map(pos)); |
| 285 | + } |
| 286 | + width += nodeWidthCache[pos]; |
| 287 | + } |
| 288 | + pos += node.nodeSize; |
| 289 | + |
| 290 | + // TODO: This assumes no space between inline sibling nodes. |
112 | 291 | } |
113 | | - return 0; |
| 292 | + return width; |
114 | 293 | } |
115 | 294 |
|
116 | 295 | function calcTabHeight(pos) { |
|
0 commit comments