commands.ts (32851B)
1 import {joinPoint, canJoin, findWrapping, liftTarget, canSplit, 2 ReplaceStep, ReplaceAroundStep, replaceStep} from "prosemirror-transform" 3 import {Slice, Fragment, Node, NodeType, Attrs, MarkType, ResolvedPos, ContentMatch} from "prosemirror-model" 4 import {Selection, EditorState, Transaction, TextSelection, NodeSelection, 5 SelectionRange, AllSelection, Command} from "prosemirror-state" 6 import {EditorView} from "prosemirror-view" 7 8 /// Delete the selection, if there is one. 9 export const deleteSelection: Command = (state, dispatch) => { 10 if (state.selection.empty) return false 11 if (dispatch) dispatch(state.tr.deleteSelection().scrollIntoView()) 12 return true 13 } 14 15 function atBlockStart(state: EditorState, view?: EditorView): ResolvedPos | null { 16 let {$cursor} = state.selection as TextSelection 17 if (!$cursor || (view ? !view.endOfTextblock("backward", state) 18 : $cursor.parentOffset > 0)) 19 return null 20 return $cursor 21 } 22 23 /// If the selection is empty and at the start of a textblock, try to 24 /// reduce the distance between that block and the one before it—if 25 /// there's a block directly before it that can be joined, join them. 26 /// If not, try to move the selected block closer to the next one in 27 /// the document structure by lifting it out of its parent or moving it 28 /// into a parent of the previous block. Will use the view for accurate 29 /// (bidi-aware) start-of-textblock detection if given. 30 export const joinBackward: Command = (state, dispatch, view) => { 31 let $cursor = atBlockStart(state, view) 32 if (!$cursor) return false 33 34 let $cut = findCutBefore($cursor) 35 36 // If there is no node before this, try to lift 37 if (!$cut) { 38 let range = $cursor.blockRange(), target = range && liftTarget(range) 39 if (target == null) return false 40 if (dispatch) dispatch(state.tr.lift(range!, target).scrollIntoView()) 41 return true 42 } 43 44 let before = $cut.nodeBefore! 45 // Apply the joining algorithm 46 if (deleteBarrier(state, $cut, dispatch, -1)) return true 47 48 // If the node below has no content and the node above is 49 // selectable, delete the node below and select the one above. 50 if ($cursor.parent.content.size == 0 && 51 (textblockAt(before, "end") || NodeSelection.isSelectable(before))) { 52 for (let depth = $cursor.depth;; depth--) { 53 let delStep = replaceStep(state.doc, $cursor.before(depth), $cursor.after(depth), Slice.empty) 54 if (delStep && (delStep as ReplaceStep).slice.size < (delStep as ReplaceStep).to - (delStep as ReplaceStep).from) { 55 if (dispatch) { 56 let tr = state.tr.step(delStep) 57 tr.setSelection(textblockAt(before, "end") 58 ? Selection.findFrom(tr.doc.resolve(tr.mapping.map($cut.pos, -1)), -1)! 59 : NodeSelection.create(tr.doc, $cut.pos - before.nodeSize)) 60 dispatch(tr.scrollIntoView()) 61 } 62 return true 63 } 64 if (depth == 1 || $cursor.node(depth - 1).childCount > 1) break 65 } 66 } 67 68 // If the node before is an atom, delete it 69 if (before.isAtom && $cut.depth == $cursor.depth - 1) { 70 if (dispatch) dispatch(state.tr.delete($cut.pos - before.nodeSize, $cut.pos).scrollIntoView()) 71 return true 72 } 73 74 return false 75 } 76 77 /// A more limited form of [`joinBackward`](#commands.joinBackward) 78 /// that only tries to join the current textblock to the one before 79 /// it, if the cursor is at the start of a textblock. 80 export const joinTextblockBackward: Command = (state, dispatch, view) => { 81 let $cursor = atBlockStart(state, view) 82 if (!$cursor) return false 83 let $cut = findCutBefore($cursor) 84 return $cut ? joinTextblocksAround(state, $cut, dispatch) : false 85 } 86 87 /// A more limited form of [`joinForward`](#commands.joinForward) 88 /// that only tries to join the current textblock to the one after 89 /// it, if the cursor is at the end of a textblock. 90 export const joinTextblockForward: Command = (state, dispatch, view) => { 91 let $cursor = atBlockEnd(state, view) 92 if (!$cursor) return false 93 let $cut = findCutAfter($cursor) 94 return $cut ? joinTextblocksAround(state, $cut, dispatch) : false 95 } 96 97 function joinTextblocksAround(state: EditorState, $cut: ResolvedPos, dispatch?: (tr: Transaction) => void) { 98 let before = $cut.nodeBefore!, beforeText = before, beforePos = $cut.pos - 1 99 for (; !beforeText.isTextblock; beforePos--) { 100 if (beforeText.type.spec.isolating) return false 101 let child = beforeText.lastChild 102 if (!child) return false 103 beforeText = child 104 } 105 let after = $cut.nodeAfter!, afterText = after, afterPos = $cut.pos + 1 106 for (; !afterText.isTextblock; afterPos++) { 107 if (afterText.type.spec.isolating) return false 108 let child = afterText.firstChild 109 if (!child) return false 110 afterText = child 111 } 112 let step = replaceStep(state.doc, beforePos, afterPos, Slice.empty) as ReplaceStep | null 113 if (!step || step.from != beforePos || 114 step instanceof ReplaceStep && step.slice.size >= afterPos - beforePos) return false 115 if (dispatch) { 116 let tr = state.tr.step(step) 117 tr.setSelection(TextSelection.create(tr.doc, beforePos)) 118 dispatch(tr.scrollIntoView()) 119 } 120 return true 121 122 } 123 124 function textblockAt(node: Node, side: "start" | "end", only = false) { 125 for (let scan: Node | null = node; scan; scan = (side == "start" ? scan.firstChild : scan.lastChild)) { 126 if (scan.isTextblock) return true 127 if (only && scan.childCount != 1) return false 128 } 129 return false 130 } 131 132 /// When the selection is empty and at the start of a textblock, select 133 /// the node before that textblock, if possible. This is intended to be 134 /// bound to keys like backspace, after 135 /// [`joinBackward`](#commands.joinBackward) or other deleting 136 /// commands, as a fall-back behavior when the schema doesn't allow 137 /// deletion at the selected point. 138 export const selectNodeBackward: Command = (state, dispatch, view) => { 139 let {$head, empty} = state.selection, $cut: ResolvedPos | null = $head 140 if (!empty) return false 141 142 if ($head.parent.isTextblock) { 143 if (view ? !view.endOfTextblock("backward", state) : $head.parentOffset > 0) return false 144 $cut = findCutBefore($head) 145 } 146 let node = $cut && $cut.nodeBefore 147 if (!node || !NodeSelection.isSelectable(node)) return false 148 if (dispatch) 149 dispatch(state.tr.setSelection(NodeSelection.create(state.doc, $cut!.pos - node.nodeSize)).scrollIntoView()) 150 return true 151 } 152 153 function findCutBefore($pos: ResolvedPos): ResolvedPos | null { 154 if (!$pos.parent.type.spec.isolating) for (let i = $pos.depth - 1; i >= 0; i--) { 155 if ($pos.index(i) > 0) return $pos.doc.resolve($pos.before(i + 1)) 156 if ($pos.node(i).type.spec.isolating) break 157 } 158 return null 159 } 160 161 function atBlockEnd(state: EditorState, view?: EditorView): ResolvedPos | null { 162 let {$cursor} = state.selection as TextSelection 163 if (!$cursor || (view ? !view.endOfTextblock("forward", state) 164 : $cursor.parentOffset < $cursor.parent.content.size)) 165 return null 166 return $cursor 167 } 168 169 /// If the selection is empty and the cursor is at the end of a 170 /// textblock, try to reduce or remove the boundary between that block 171 /// and the one after it, either by joining them or by moving the other 172 /// block closer to this one in the tree structure. Will use the view 173 /// for accurate start-of-textblock detection if given. 174 export const joinForward: Command = (state, dispatch, view) => { 175 let $cursor = atBlockEnd(state, view) 176 if (!$cursor) return false 177 178 let $cut = findCutAfter($cursor) 179 // If there is no node after this, there's nothing to do 180 if (!$cut) return false 181 182 let after = $cut.nodeAfter! 183 // Try the joining algorithm 184 if (deleteBarrier(state, $cut, dispatch, 1)) return true 185 186 // If the node above has no content and the node below is 187 // selectable, delete the node above and select the one below. 188 if ($cursor.parent.content.size == 0 && 189 (textblockAt(after, "start") || NodeSelection.isSelectable(after))) { 190 let delStep = replaceStep(state.doc, $cursor.before(), $cursor.after(), Slice.empty) 191 if (delStep && (delStep as ReplaceStep).slice.size < (delStep as ReplaceStep).to - (delStep as ReplaceStep).from) { 192 if (dispatch) { 193 let tr = state.tr.step(delStep) 194 tr.setSelection(textblockAt(after, "start") ? Selection.findFrom(tr.doc.resolve(tr.mapping.map($cut.pos)), 1)! 195 : NodeSelection.create(tr.doc, tr.mapping.map($cut.pos))) 196 dispatch(tr.scrollIntoView()) 197 } 198 return true 199 } 200 } 201 202 // If the next node is an atom, delete it 203 if (after.isAtom && $cut.depth == $cursor.depth - 1) { 204 if (dispatch) dispatch(state.tr.delete($cut.pos, $cut.pos + after.nodeSize).scrollIntoView()) 205 return true 206 } 207 208 return false 209 } 210 211 /// When the selection is empty and at the end of a textblock, select 212 /// the node coming after that textblock, if possible. This is intended 213 /// to be bound to keys like delete, after 214 /// [`joinForward`](#commands.joinForward) and similar deleting 215 /// commands, to provide a fall-back behavior when the schema doesn't 216 /// allow deletion at the selected point. 217 export const selectNodeForward: Command = (state, dispatch, view) => { 218 let {$head, empty} = state.selection, $cut: ResolvedPos | null = $head 219 if (!empty) return false 220 if ($head.parent.isTextblock) { 221 if (view ? !view.endOfTextblock("forward", state) : $head.parentOffset < $head.parent.content.size) 222 return false 223 $cut = findCutAfter($head) 224 } 225 let node = $cut && $cut.nodeAfter 226 if (!node || !NodeSelection.isSelectable(node)) return false 227 if (dispatch) 228 dispatch(state.tr.setSelection(NodeSelection.create(state.doc, $cut!.pos)).scrollIntoView()) 229 return true 230 } 231 232 function findCutAfter($pos: ResolvedPos) { 233 if (!$pos.parent.type.spec.isolating) for (let i = $pos.depth - 1; i >= 0; i--) { 234 let parent = $pos.node(i) 235 if ($pos.index(i) + 1 < parent.childCount) return $pos.doc.resolve($pos.after(i + 1)) 236 if (parent.type.spec.isolating) break 237 } 238 return null 239 } 240 241 /// Join the selected block or, if there is a text selection, the 242 /// closest ancestor block of the selection that can be joined, with 243 /// the sibling above it. 244 export const joinUp: Command = (state, dispatch) => { 245 let sel = state.selection, nodeSel = sel instanceof NodeSelection, point 246 if (nodeSel) { 247 if ((sel as NodeSelection).node.isTextblock || !canJoin(state.doc, sel.from)) return false 248 point = sel.from 249 } else { 250 point = joinPoint(state.doc, sel.from, -1) 251 if (point == null) return false 252 } 253 if (dispatch) { 254 let tr = state.tr.join(point) 255 if (nodeSel) tr.setSelection(NodeSelection.create(tr.doc, point - state.doc.resolve(point).nodeBefore!.nodeSize)) 256 dispatch(tr.scrollIntoView()) 257 } 258 return true 259 } 260 261 /// Join the selected block, or the closest ancestor of the selection 262 /// that can be joined, with the sibling after it. 263 export const joinDown: Command = (state, dispatch) => { 264 let sel = state.selection, point 265 if (sel instanceof NodeSelection) { 266 if (sel.node.isTextblock || !canJoin(state.doc, sel.to)) return false 267 point = sel.to 268 } else { 269 point = joinPoint(state.doc, sel.to, 1) 270 if (point == null) return false 271 } 272 if (dispatch) 273 dispatch(state.tr.join(point).scrollIntoView()) 274 return true 275 } 276 277 /// Lift the selected block, or the closest ancestor block of the 278 /// selection that can be lifted, out of its parent node. 279 export const lift: Command = (state, dispatch) => { 280 let {$from, $to} = state.selection 281 let range = $from.blockRange($to), target = range && liftTarget(range) 282 if (target == null) return false 283 if (dispatch) dispatch(state.tr.lift(range!, target).scrollIntoView()) 284 return true 285 } 286 287 /// If the selection is in a node whose type has a truthy 288 /// [`code`](#model.NodeSpec.code) property in its spec, replace the 289 /// selection with a newline character. 290 export const newlineInCode: Command = (state, dispatch) => { 291 let {$head, $anchor} = state.selection 292 if (!$head.parent.type.spec.code || !$head.sameParent($anchor)) return false 293 if (dispatch) dispatch(state.tr.insertText("\n").scrollIntoView()) 294 return true 295 } 296 297 function defaultBlockAt(match: ContentMatch) { 298 for (let i = 0; i < match.edgeCount; i++) { 299 let {type} = match.edge(i) 300 if (type.isTextblock && !type.hasRequiredAttrs()) return type 301 } 302 return null 303 } 304 305 /// When the selection is in a node with a truthy 306 /// [`code`](#model.NodeSpec.code) property in its spec, create a 307 /// default block after the code block, and move the cursor there. 308 export const exitCode: Command = (state, dispatch) => { 309 let {$head, $anchor} = state.selection 310 if (!$head.parent.type.spec.code || !$head.sameParent($anchor)) return false 311 let above = $head.node(-1), after = $head.indexAfter(-1), type = defaultBlockAt(above.contentMatchAt(after)) 312 if (!type || !above.canReplaceWith(after, after, type)) return false 313 if (dispatch) { 314 let pos = $head.after(), tr = state.tr.replaceWith(pos, pos, type.createAndFill()!) 315 tr.setSelection(Selection.near(tr.doc.resolve(pos), 1)) 316 dispatch(tr.scrollIntoView()) 317 } 318 return true 319 } 320 321 /// If a block node is selected, create an empty paragraph before (if 322 /// it is its parent's first child) or after it. 323 export const createParagraphNear: Command = (state, dispatch) => { 324 let sel = state.selection, {$from, $to} = sel 325 if (sel instanceof AllSelection || $from.parent.inlineContent || $to.parent.inlineContent) return false 326 let type = defaultBlockAt($to.parent.contentMatchAt($to.indexAfter())) 327 if (!type || !type.isTextblock) return false 328 if (dispatch) { 329 let side = (!$from.parentOffset && $to.index() < $to.parent.childCount ? $from : $to).pos 330 let tr = state.tr.insert(side, type.createAndFill()!) 331 tr.setSelection(TextSelection.create(tr.doc, side + 1)) 332 dispatch(tr.scrollIntoView()) 333 } 334 return true 335 } 336 337 /// If the cursor is in an empty textblock that can be lifted, lift the 338 /// block. 339 export const liftEmptyBlock: Command = (state, dispatch) => { 340 let {$cursor} = state.selection as TextSelection 341 if (!$cursor || $cursor.parent.content.size) return false 342 if ($cursor.depth > 1 && $cursor.after() != $cursor.end(-1)) { 343 let before = $cursor.before() 344 if (canSplit(state.doc, before)) { 345 if (dispatch) dispatch(state.tr.split(before).scrollIntoView()) 346 return true 347 } 348 } 349 let range = $cursor.blockRange(), target = range && liftTarget(range) 350 if (target == null) return false 351 if (dispatch) dispatch(state.tr.lift(range!, target).scrollIntoView()) 352 return true 353 } 354 355 /// Create a variant of [`splitBlock`](#commands.splitBlock) that uses 356 /// a custom function to determine the type of the newly split off block. 357 export function splitBlockAs( 358 splitNode?: (node: Node, atEnd: boolean, $from: ResolvedPos) => {type: NodeType, attrs?: Attrs} | null 359 ): Command { 360 return (state, dispatch) => { 361 let {$from, $to} = state.selection 362 if (state.selection instanceof NodeSelection && state.selection.node.isBlock) { 363 if (!$from.parentOffset || !canSplit(state.doc, $from.pos)) return false 364 if (dispatch) dispatch(state.tr.split($from.pos).scrollIntoView()) 365 return true 366 } 367 368 if (!$from.depth) return false 369 let types: (null | {type: NodeType, attrs?: Attrs | null})[] = [] 370 let splitDepth, deflt, atEnd = false, atStart = false 371 for (let d = $from.depth;; d--) { 372 let node = $from.node(d) 373 if (node.isBlock) { 374 atEnd = $from.end(d) == $from.pos + ($from.depth - d) 375 atStart = $from.start(d) == $from.pos - ($from.depth - d) 376 deflt = defaultBlockAt($from.node(d - 1).contentMatchAt($from.indexAfter(d - 1))) 377 let splitType = splitNode && splitNode($to.parent, atEnd, $from) 378 types.unshift(splitType || (atEnd && deflt ? {type: deflt} : null)) 379 splitDepth = d 380 break 381 } else { 382 if (d == 1) return false 383 types.unshift(null) 384 } 385 } 386 387 let tr = state.tr 388 if (state.selection instanceof TextSelection || state.selection instanceof AllSelection) tr.deleteSelection() 389 let splitPos = tr.mapping.map($from.pos) 390 let can = canSplit(tr.doc, splitPos, types.length, types) 391 if (!can) { 392 types[0] = deflt ? {type: deflt} : null 393 can = canSplit(tr.doc, splitPos, types.length, types) 394 } 395 if (!can) return false 396 tr.split(splitPos, types.length, types) 397 if (!atEnd && atStart && $from.node(splitDepth).type != deflt) { 398 let first = tr.mapping.map($from.before(splitDepth)), $first = tr.doc.resolve(first) 399 if (deflt && $from.node(splitDepth - 1).canReplaceWith($first.index(), $first.index() + 1, deflt)) 400 tr.setNodeMarkup(tr.mapping.map($from.before(splitDepth)), deflt) 401 } 402 if (dispatch) dispatch(tr.scrollIntoView()) 403 return true 404 } 405 } 406 407 /// Split the parent block of the selection. If the selection is a text 408 /// selection, also delete its content. 409 export const splitBlock: Command = splitBlockAs() 410 411 /// Acts like [`splitBlock`](#commands.splitBlock), but without 412 /// resetting the set of active marks at the cursor. 413 export const splitBlockKeepMarks: Command = (state, dispatch) => { 414 return splitBlock(state, dispatch && (tr => { 415 let marks = state.storedMarks || (state.selection.$to.parentOffset && state.selection.$from.marks()) 416 if (marks) tr.ensureMarks(marks) 417 dispatch(tr) 418 })) 419 } 420 421 /// Move the selection to the node wrapping the current selection, if 422 /// any. (Will not select the document node.) 423 export const selectParentNode: Command = (state, dispatch) => { 424 let {$from, to} = state.selection, pos 425 let same = $from.sharedDepth(to) 426 if (same == 0) return false 427 pos = $from.before(same) 428 if (dispatch) dispatch(state.tr.setSelection(NodeSelection.create(state.doc, pos))) 429 return true 430 } 431 432 /// Select the whole document. 433 export const selectAll: Command = (state, dispatch) => { 434 if (dispatch) dispatch(state.tr.setSelection(new AllSelection(state.doc))) 435 return true 436 } 437 438 function joinMaybeClear(state: EditorState, $pos: ResolvedPos, dispatch: ((tr: Transaction) => void) | undefined) { 439 let before = $pos.nodeBefore, after = $pos.nodeAfter, index = $pos.index() 440 if (!before || !after || !before.type.compatibleContent(after.type)) return false 441 if (!before.content.size && $pos.parent.canReplace(index - 1, index)) { 442 if (dispatch) dispatch(state.tr.delete($pos.pos - before.nodeSize, $pos.pos).scrollIntoView()) 443 return true 444 } 445 if (!$pos.parent.canReplace(index, index + 1) || !(after.isTextblock || canJoin(state.doc, $pos.pos))) 446 return false 447 if (dispatch) 448 dispatch(state.tr.join($pos.pos).scrollIntoView()) 449 return true 450 } 451 452 function deleteBarrier(state: EditorState, $cut: ResolvedPos, dispatch: ((tr: Transaction) => void) | undefined, dir: number) { 453 let before = $cut.nodeBefore!, after = $cut.nodeAfter!, conn, match 454 let isolated = before.type.spec.isolating || after.type.spec.isolating 455 if (!isolated && joinMaybeClear(state, $cut, dispatch)) return true 456 457 let canDelAfter = !isolated && $cut.parent.canReplace($cut.index(), $cut.index() + 1) 458 if (canDelAfter && 459 (conn = (match = before.contentMatchAt(before.childCount)).findWrapping(after.type)) && 460 match.matchType(conn[0] || after.type)!.validEnd) { 461 if (dispatch) { 462 let end = $cut.pos + after.nodeSize, wrap = Fragment.empty 463 for (let i = conn.length - 1; i >= 0; i--) 464 wrap = Fragment.from(conn[i].create(null, wrap)) 465 wrap = Fragment.from(before.copy(wrap)) 466 let tr = state.tr.step(new ReplaceAroundStep($cut.pos - 1, end, $cut.pos, end, new Slice(wrap, 1, 0), conn.length, true)) 467 let $joinAt = tr.doc.resolve(end + 2 * conn.length) 468 if ($joinAt.nodeAfter && $joinAt.nodeAfter.type == before.type && 469 canJoin(tr.doc, $joinAt.pos)) tr.join($joinAt.pos) 470 dispatch(tr.scrollIntoView()) 471 } 472 return true 473 } 474 475 let selAfter = after.type.spec.isolating || (dir > 0 && isolated) ? null : Selection.findFrom($cut, 1) 476 let range = selAfter && selAfter.$from.blockRange(selAfter.$to), target = range && liftTarget(range) 477 if (target != null && target >= $cut.depth) { 478 if (dispatch) dispatch(state.tr.lift(range!, target).scrollIntoView()) 479 return true 480 } 481 482 if (canDelAfter && textblockAt(after, "start", true) && textblockAt(before, "end")) { 483 let at = before, wrap = [] 484 for (;;) { 485 wrap.push(at) 486 if (at.isTextblock) break 487 at = at.lastChild! 488 } 489 let afterText = after, afterDepth = 1 490 for (; !afterText.isTextblock; afterText = afterText.firstChild!) afterDepth++ 491 if (at.canReplace(at.childCount, at.childCount, afterText.content)) { 492 if (dispatch) { 493 let end = Fragment.empty 494 for (let i = wrap.length - 1; i >= 0; i--) end = Fragment.from(wrap[i].copy(end)) 495 let tr = state.tr.step(new ReplaceAroundStep($cut.pos - wrap.length, $cut.pos + after.nodeSize, 496 $cut.pos + afterDepth, $cut.pos + after.nodeSize - afterDepth, 497 new Slice(end, wrap.length, 0), 0, true)) 498 dispatch(tr.scrollIntoView()) 499 } 500 return true 501 } 502 } 503 504 return false 505 } 506 507 function selectTextblockSide(side: number): Command { 508 return function(state, dispatch) { 509 let sel = state.selection, $pos = side < 0 ? sel.$from : sel.$to 510 let depth = $pos.depth 511 while ($pos.node(depth).isInline) { 512 if (!depth) return false 513 depth-- 514 } 515 if (!$pos.node(depth).isTextblock) return false 516 if (dispatch) 517 dispatch(state.tr.setSelection(TextSelection.create( 518 state.doc, side < 0 ? $pos.start(depth) : $pos.end(depth)))) 519 return true 520 } 521 } 522 523 /// Moves the cursor to the start of current text block. 524 export const selectTextblockStart = selectTextblockSide(-1) 525 526 /// Moves the cursor to the end of current text block. 527 export const selectTextblockEnd = selectTextblockSide(1) 528 529 // Parameterized commands 530 531 /// Wrap the selection in a node of the given type with the given 532 /// attributes. 533 export function wrapIn(nodeType: NodeType, attrs: Attrs | null = null): Command { 534 return function(state, dispatch) { 535 let {$from, $to} = state.selection 536 let range = $from.blockRange($to), wrapping = range && findWrapping(range, nodeType, attrs) 537 if (!wrapping) return false 538 if (dispatch) dispatch(state.tr.wrap(range!, wrapping).scrollIntoView()) 539 return true 540 } 541 } 542 543 /// Returns a command that tries to set the selected textblocks to the 544 /// given node type with the given attributes. 545 export function setBlockType(nodeType: NodeType, attrs: Attrs | null = null): Command { 546 return function(state, dispatch) { 547 let applicable = false 548 for (let i = 0; i < state.selection.ranges.length && !applicable; i++) { 549 let {$from: {pos: from}, $to: {pos: to}} = state.selection.ranges[i] 550 state.doc.nodesBetween(from, to, (node, pos) => { 551 if (applicable) return false 552 if (!node.isTextblock || node.hasMarkup(nodeType, attrs)) return 553 if (node.type == nodeType) { 554 applicable = true 555 } else { 556 let $pos = state.doc.resolve(pos), index = $pos.index() 557 applicable = $pos.parent.canReplaceWith(index, index + 1, nodeType) 558 } 559 }) 560 } 561 if (!applicable) return false 562 if (dispatch) { 563 let tr = state.tr 564 for (let i = 0; i < state.selection.ranges.length; i++) { 565 let {$from: {pos: from}, $to: {pos: to}} = state.selection.ranges[i] 566 tr.setBlockType(from, to, nodeType, attrs) 567 } 568 dispatch(tr.scrollIntoView()) 569 } 570 return true 571 } 572 } 573 574 function markApplies(doc: Node, ranges: readonly SelectionRange[], type: MarkType, enterAtoms: boolean) { 575 for (let i = 0; i < ranges.length; i++) { 576 let {$from, $to} = ranges[i] 577 let can = $from.depth == 0 ? doc.inlineContent && doc.type.allowsMarkType(type) : false 578 doc.nodesBetween($from.pos, $to.pos, (node, pos) => { 579 if (can || !enterAtoms && node.isAtom && node.isInline && pos >= $from.pos && pos + node.nodeSize <= $to.pos) 580 return false 581 can = node.inlineContent && node.type.allowsMarkType(type) 582 }) 583 if (can) return true 584 } 585 return false 586 } 587 588 function removeInlineAtoms(ranges: readonly SelectionRange[]): readonly SelectionRange[] { 589 let result = [] 590 for (let i = 0; i < ranges.length; i++) { 591 let {$from, $to} = ranges[i] 592 $from.doc.nodesBetween($from.pos, $to.pos, (node, pos) => { 593 if (node.isAtom && node.content.size && node.isInline && pos >= $from.pos && pos + node.nodeSize <= $to.pos) { 594 if (pos + 1 > $from.pos) result.push(new SelectionRange($from, $from.doc.resolve(pos + 1))) 595 $from = $from.doc.resolve(pos + 1 + node.content.size) 596 return false 597 } 598 }) 599 if ($from.pos < $to.pos) result.push(new SelectionRange($from, $to)) 600 } 601 return result 602 } 603 604 /// Create a command function that toggles the given mark with the 605 /// given attributes. Will return `false` when the current selection 606 /// doesn't support that mark. This will remove the mark if any marks 607 /// of that type exist in the selection, or add it otherwise. If the 608 /// selection is empty, this applies to the [stored 609 /// marks](#state.EditorState.storedMarks) instead of a range of the 610 /// document. 611 export function toggleMark(markType: MarkType, attrs: Attrs | null = null, options?: { 612 /// Controls whether, when part of the selected range has the mark 613 /// already and part doesn't, the mark is removed (`true`, the 614 /// default) or added (`false`). 615 removeWhenPresent?: boolean 616 /// When set to false, this will prevent the command from acting on 617 /// the content of inline nodes marked as 618 /// [atoms](#model.NodeSpec.atom) that are completely covered by a 619 /// selection range. 620 enterInlineAtoms?: boolean 621 /// By default, this command doesn't apply to leading and trailing 622 /// whitespace in the selection. Set this to `true` to change that. 623 includeWhitespace?: boolean 624 }): Command { 625 let removeWhenPresent = (options && options.removeWhenPresent) !== false 626 let enterAtoms = (options && options.enterInlineAtoms) !== false 627 let dropSpace = !(options && options.includeWhitespace) 628 return function(state, dispatch) { 629 let {empty, $cursor, ranges} = state.selection as TextSelection 630 if ((empty && !$cursor) || !markApplies(state.doc, ranges, markType, enterAtoms)) return false 631 if (dispatch) { 632 if ($cursor) { 633 if (markType.isInSet(state.storedMarks || $cursor.marks())) 634 dispatch(state.tr.removeStoredMark(markType)) 635 else 636 dispatch(state.tr.addStoredMark(markType.create(attrs))) 637 } else { 638 let add, tr = state.tr 639 if (!enterAtoms) ranges = removeInlineAtoms(ranges) 640 if (removeWhenPresent) { 641 add = !ranges.some(r => state.doc.rangeHasMark(r.$from.pos, r.$to.pos, markType)) 642 } else { 643 add = !ranges.every(r => { 644 let missing = false 645 tr.doc.nodesBetween(r.$from.pos, r.$to.pos, (node, pos, parent) => { 646 if (missing) return false 647 missing = !markType.isInSet(node.marks) && !!parent && parent.type.allowsMarkType(markType) && 648 !(node.isText && /^\s*$/.test(node.textBetween(Math.max(0, r.$from.pos - pos), 649 Math.min(node.nodeSize, r.$to.pos - pos)))) 650 }) 651 return !missing 652 }) 653 } 654 for (let i = 0; i < ranges.length; i++) { 655 let {$from, $to} = ranges[i] 656 if (!add) { 657 tr.removeMark($from.pos, $to.pos, markType) 658 } else { 659 let from = $from.pos, to = $to.pos, start = $from.nodeAfter, end = $to.nodeBefore 660 let spaceStart = dropSpace && start && start.isText ? /^\s*/.exec(start.text!)![0].length : 0 661 let spaceEnd = dropSpace && end && end.isText ? /\s*$/.exec(end.text!)![0].length : 0 662 if (from + spaceStart < to) { from += spaceStart; to -= spaceEnd } 663 tr.addMark(from, to, markType.create(attrs)) 664 } 665 } 666 dispatch(tr.scrollIntoView()) 667 } 668 } 669 return true 670 } 671 } 672 673 function wrapDispatchForJoin(dispatch: (tr: Transaction) => void, isJoinable: (a: Node, b: Node) => boolean) { 674 return (tr: Transaction) => { 675 if (!tr.isGeneric) return dispatch(tr) 676 677 let ranges: number[] = [] 678 for (let i = 0; i < tr.mapping.maps.length; i++) { 679 let map = tr.mapping.maps[i] 680 for (let j = 0; j < ranges.length; j++) 681 ranges[j] = map.map(ranges[j]) 682 map.forEach((_s, _e, from, to) => ranges.push(from, to)) 683 } 684 685 // Figure out which joinable points exist inside those ranges, 686 // by checking all node boundaries in their parent nodes. 687 let joinable = [] 688 for (let i = 0; i < ranges.length; i += 2) { 689 let from = ranges[i], to = ranges[i + 1] 690 let $from = tr.doc.resolve(from), depth = $from.sharedDepth(to), parent = $from.node(depth) 691 for (let index = $from.indexAfter(depth), pos = $from.after(depth + 1); pos <= to; ++index) { 692 let after = parent.maybeChild(index) 693 if (!after) break 694 if (index && joinable.indexOf(pos) == -1) { 695 let before = parent.child(index - 1) 696 if (before.type == after.type && isJoinable(before, after)) 697 joinable.push(pos) 698 } 699 pos += after.nodeSize 700 } 701 } 702 // Join the joinable points 703 joinable.sort((a, b) => a - b) 704 for (let i = joinable.length - 1; i >= 0; i--) { 705 if (canJoin(tr.doc, joinable[i])) tr.join(joinable[i]) 706 } 707 dispatch(tr) 708 } 709 } 710 711 /// Wrap a command so that, when it produces a transform that causes 712 /// two joinable nodes to end up next to each other, those are joined. 713 /// Nodes are considered joinable when they are of the same type and 714 /// when the `isJoinable` predicate returns true for them or, if an 715 /// array of strings was passed, if their node type name is in that 716 /// array. 717 export function autoJoin( 718 command: Command, 719 isJoinable: ((before: Node, after: Node) => boolean) | readonly string[] 720 ): Command { 721 let canJoin = Array.isArray(isJoinable) ? (node: Node) => isJoinable.indexOf(node.type.name) > -1 722 : isJoinable as (a: Node, b: Node) => boolean 723 return (state, dispatch, view) => command(state, dispatch && wrapDispatchForJoin(dispatch, canJoin), view) 724 } 725 726 /// Combine a number of command functions into a single function (which 727 /// calls them one by one until one returns true). 728 export function chainCommands(...commands: readonly Command[]): Command { 729 return function(state, dispatch, view) { 730 for (let i = 0; i < commands.length; i++) 731 if (commands[i](state, dispatch, view)) return true 732 return false 733 } 734 } 735 736 let backspace = chainCommands(deleteSelection, joinBackward, selectNodeBackward) 737 let del = chainCommands(deleteSelection, joinForward, selectNodeForward) 738 739 /// A basic keymap containing bindings not specific to any schema. 740 /// Binds the following keys (when multiple commands are listed, they 741 /// are chained with [`chainCommands`](#commands.chainCommands)): 742 /// 743 /// * **Enter** to `newlineInCode`, `createParagraphNear`, `liftEmptyBlock`, `splitBlock` 744 /// * **Mod-Enter** to `exitCode` 745 /// * **Backspace** and **Mod-Backspace** to `deleteSelection`, `joinBackward`, `selectNodeBackward` 746 /// * **Delete** and **Mod-Delete** to `deleteSelection`, `joinForward`, `selectNodeForward` 747 /// * **Mod-Delete** to `deleteSelection`, `joinForward`, `selectNodeForward` 748 /// * **Mod-a** to `selectAll` 749 export const pcBaseKeymap: {[key: string]: Command} = { 750 "Enter": chainCommands(newlineInCode, createParagraphNear, liftEmptyBlock, splitBlock), 751 "Mod-Enter": exitCode, 752 "Backspace": backspace, 753 "Mod-Backspace": backspace, 754 "Shift-Backspace": backspace, 755 "Delete": del, 756 "Mod-Delete": del, 757 "Mod-a": selectAll 758 } 759 760 /// A copy of `pcBaseKeymap` that also binds **Ctrl-h** like Backspace, 761 /// **Ctrl-d** like Delete, **Alt-Backspace** like Ctrl-Backspace, and 762 /// **Ctrl-Alt-Backspace**, **Alt-Delete**, and **Alt-d** like 763 /// Ctrl-Delete. 764 export const macBaseKeymap: {[key: string]: Command} = { 765 "Ctrl-h": pcBaseKeymap["Backspace"], 766 "Alt-Backspace": pcBaseKeymap["Mod-Backspace"], 767 "Ctrl-d": pcBaseKeymap["Delete"], 768 "Ctrl-Alt-Backspace": pcBaseKeymap["Mod-Delete"], 769 "Alt-Delete": pcBaseKeymap["Mod-Delete"], 770 "Alt-d": pcBaseKeymap["Mod-Delete"], 771 "Ctrl-a": selectTextblockStart, 772 "Ctrl-e": selectTextblockEnd 773 } 774 for (let key in pcBaseKeymap) (macBaseKeymap as any)[key] = pcBaseKeymap[key] 775 776 const mac = typeof navigator != "undefined" ? /Mac|iP(hone|[oa]d)/.test(navigator.platform) 777 // @ts-ignore 778 : typeof os != "undefined" && os.platform ? os.platform() == "darwin" : false 779 780 /// Depending on the detected platform, this will hold 781 /// [`pcBasekeymap`](#commands.pcBaseKeymap) or 782 /// [`macBaseKeymap`](#commands.macBaseKeymap). 783 export const baseKeymap: {[key: string]: Command} = mac ? macBaseKeymap : pcBaseKeymap