tor-browser

The Tor Browser
git clone https://git.dasho.dev/tor-browser.git
Log | Files | Refs | README | LICENSE

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