-
Notifications
You must be signed in to change notification settings - Fork 5
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Merge pull request #1027 from lblod/feature/custom-gap-cursor
GN-4587: custom gap-cursor plugin
- Loading branch information
Showing
4 changed files
with
356 additions
and
1 deletion.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,9 @@ | ||
--- | ||
"@lblod/ember-rdfa-editor": minor | ||
--- | ||
|
||
Introduction of a custom gap-cursor plugin containing several fixes compared to the original version: | ||
|
||
- The click handler has been replaced by a mousedown handler in order to intercept a click event earlier | ||
- The types of the GapCursor class have been fixed | ||
- Addition of a fix when resolving the position returned by view.posAtCoords. |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,190 @@ | ||
import { Selection, NodeSelection } from 'prosemirror-state'; | ||
import { Slice, ResolvedPos, Node } from 'prosemirror-model'; | ||
/** | ||
* Based on code from https://github.com/ProseMirror/prosemirror-gapcursor | ||
* | ||
* Copyright (C) 2015-2017 by Marijn Haverbeke <marijn@haverbeke.berlin> and others | ||
* | ||
* Permission is hereby granted, free of charge, to any person obtaining a copy | ||
* of this software and associated documentation files (the "Software"), to deal | ||
* in the Software without restriction, including without limitation the rights | ||
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell | ||
* copies of the Software, and to permit persons to whom the Software is | ||
* furnished to do so, subject to the following conditions: | ||
* | ||
* The above copyright notice and this permission notice shall be included in | ||
* all copies or substantial portions of the Software. | ||
* | ||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR | ||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, | ||
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE | ||
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER | ||
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, | ||
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN | ||
* THE SOFTWARE. | ||
*/ | ||
|
||
import { Mappable } from 'prosemirror-transform'; | ||
import { unwrap } from '@lblod/ember-rdfa-editor/utils/_private/option'; | ||
|
||
/// Gap cursor selections are represented using this class. Its | ||
/// `$anchor` and `$head` properties both point at the cursor position. | ||
export class GapCursor extends Selection { | ||
visible = false; | ||
/// Create a gap cursor. | ||
constructor($pos: ResolvedPos) { | ||
super($pos, $pos); | ||
} | ||
|
||
map(doc: Node, mapping: Mappable): Selection { | ||
const $pos = doc.resolve(mapping.map(this.head)); | ||
return GapCursor.valid($pos) ? new GapCursor($pos) : Selection.near($pos); | ||
} | ||
|
||
content() { | ||
return Slice.empty; | ||
} | ||
|
||
eq(other: Selection): boolean { | ||
return other instanceof GapCursor && other.head == this.head; | ||
} | ||
|
||
toJSON(): unknown { | ||
return { type: 'gapcursor', pos: this.head }; | ||
} | ||
|
||
/// @internal | ||
static fromJSON(doc: Node, json: { pos: unknown }): GapCursor { | ||
if (typeof json.pos != 'number') | ||
throw new RangeError('Invalid input for GapCursor.fromJSON'); | ||
return new GapCursor(doc.resolve(json.pos)); | ||
} | ||
|
||
/// @internal | ||
getBookmark() { | ||
return new GapBookmark(this.anchor); | ||
} | ||
|
||
/// @internal | ||
static valid($pos: ResolvedPos) { | ||
const parent = $pos.parent; | ||
if (parent.isTextblock || !closedBefore($pos) || !closedAfter($pos)) | ||
return false; | ||
const override = parent.type.spec.allowGapCursor as boolean | null; | ||
if (override != null) return override; | ||
const deflt = parent.contentMatchAt($pos.index()).defaultType; | ||
return deflt && deflt.isTextblock; | ||
} | ||
|
||
/// @internal | ||
static findGapCursorFrom($pos: ResolvedPos, dir: number, mustMove = false) { | ||
search: for (;;) { | ||
if (!mustMove && GapCursor.valid($pos)) return new GapCursor($pos); | ||
let pos = $pos.pos, | ||
next = null; | ||
// Scan up from this position | ||
for (let d = $pos.depth; ; d--) { | ||
const parent = $pos.node(d); | ||
if ( | ||
dir > 0 ? $pos.indexAfter(d) < parent.childCount : $pos.index(d) > 0 | ||
) { | ||
next = parent.child(dir > 0 ? $pos.indexAfter(d) : $pos.index(d) - 1); | ||
break; | ||
} else if (d == 0) { | ||
return null; | ||
} | ||
pos += dir; | ||
const $cur = $pos.doc.resolve(pos); | ||
if (GapCursor.valid($cur)) return new GapCursor($cur); | ||
} | ||
|
||
// And then down into the next node | ||
for (;;) { | ||
const inside: Node | null = dir > 0 ? next.firstChild : next.lastChild; | ||
if (!inside) { | ||
if ( | ||
next.isAtom && | ||
!next.isText && | ||
!NodeSelection.isSelectable(next) | ||
) { | ||
$pos = $pos.doc.resolve(pos + next.nodeSize * dir); | ||
mustMove = false; | ||
continue search; | ||
} | ||
break; | ||
} | ||
next = inside; | ||
pos += dir; | ||
const $cur = $pos.doc.resolve(pos); | ||
if (GapCursor.valid($cur)) return new GapCursor($cur); | ||
} | ||
|
||
return null; | ||
} | ||
} | ||
|
||
static findFrom = this.findGapCursorFrom; | ||
} | ||
|
||
Selection.jsonID('gapcursor', GapCursor); | ||
|
||
class GapBookmark { | ||
constructor(readonly pos: number) {} | ||
|
||
map(mapping: Mappable) { | ||
return new GapBookmark(mapping.map(this.pos)); | ||
} | ||
resolve(doc: Node) { | ||
const $pos = doc.resolve(this.pos); | ||
return GapCursor.valid($pos) ? new GapCursor($pos) : Selection.near($pos); | ||
} | ||
} | ||
|
||
function closedBefore($pos: ResolvedPos) { | ||
for (let d = $pos.depth; d >= 0; d--) { | ||
const index = $pos.index(d), | ||
parent = $pos.node(d); | ||
// At the start of this parent, look at next one | ||
if (index == 0) { | ||
if (parent.type.spec.isolating) return true; | ||
continue; | ||
} | ||
// See if the node before (or its first ancestor) is closed | ||
for ( | ||
let before = parent.child(index - 1); | ||
; | ||
before = unwrap(before.lastChild) | ||
) { | ||
if ( | ||
(before.childCount == 0 && !before.inlineContent) || | ||
before.isAtom || | ||
before.type.spec.isolating | ||
) | ||
return true; | ||
if (before.inlineContent) return false; | ||
} | ||
} | ||
// Hit start of document | ||
return true; | ||
} | ||
|
||
function closedAfter($pos: ResolvedPos) { | ||
for (let d = $pos.depth; d >= 0; d--) { | ||
const index = $pos.indexAfter(d), | ||
parent = $pos.node(d); | ||
if (index == parent.childCount) { | ||
if (parent.type.spec.isolating) return true; | ||
continue; | ||
} | ||
for (let after = parent.child(index); ; after = unwrap(after.firstChild)) { | ||
if ( | ||
(after.childCount == 0 && !after.inlineContent) || | ||
after.isAtom || | ||
after.type.spec.isolating | ||
) | ||
return true; | ||
if (after.inlineContent) return false; | ||
} | ||
} | ||
return true; | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,155 @@ | ||
import { keydownHandler } from 'prosemirror-keymap'; | ||
import { TextSelection, Plugin, Command, EditorState } from 'prosemirror-state'; | ||
import { Fragment, Slice } from 'prosemirror-model'; | ||
import { Decoration, DecorationSet, EditorView } from 'prosemirror-view'; | ||
import { GapCursor } from './gap-cursor'; | ||
import { unwrap } from '@lblod/ember-rdfa-editor/utils/_private/option'; | ||
|
||
/** | ||
* | ||
* Modified `gapCursor` plugin based on https://github.com/ProseMirror/prosemirror-gapcursor | ||
* | ||
* - Replaces the 'click' handler by a 'mousedown' handler in order to be able to intercept the mouse-event earlier | ||
* - Includes changes on the 'click' handler in order to correct the output provided by the `view.posAtCoords` method | ||
* | ||
* Copyright (C) 2015-2017 by Marijn Haverbeke <marijn@haverbeke.berlin> and others | ||
* | ||
* Permission is hereby granted, free of charge, to any person obtaining a copy | ||
* of this software and associated documentation files (the "Software"), to deal | ||
* in the Software without restriction, including without limitation the rights | ||
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell | ||
* copies of the Software, and to permit persons to whom the Software is | ||
* furnished to do so, subject to the following conditions: | ||
* | ||
* The above copyright notice and this permission notice shall be included in | ||
* all copies or substantial portions of the Software. | ||
* | ||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR | ||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, | ||
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE | ||
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER | ||
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, | ||
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN | ||
* THE SOFTWARE. | ||
* */ | ||
export function gapCursor(): Plugin { | ||
return new Plugin({ | ||
props: { | ||
decorations: drawGapCursor, | ||
|
||
createSelectionBetween(_view, $anchor, $head) { | ||
return $anchor.pos == $head.pos && GapCursor.valid($head) | ||
? new GapCursor($head) | ||
: null; | ||
}, | ||
|
||
// handleClick, | ||
handleKeyDown, | ||
handleDOMEvents: { | ||
beforeinput, | ||
mousedown: mousedown, | ||
}, | ||
}, | ||
}); | ||
} | ||
|
||
export { GapCursor }; | ||
|
||
const handleKeyDown = keydownHandler({ | ||
ArrowLeft: arrow('horiz', -1), | ||
ArrowRight: arrow('horiz', 1), | ||
ArrowUp: arrow('vert', -1), | ||
ArrowDown: arrow('vert', 1), | ||
}); | ||
|
||
function arrow(axis: 'vert' | 'horiz', dir: number): Command { | ||
const dirStr = | ||
axis == 'vert' ? (dir > 0 ? 'down' : 'up') : dir > 0 ? 'right' : 'left'; | ||
return function (state, dispatch, view) { | ||
const sel = state.selection; | ||
let $start = dir > 0 ? sel.$to : sel.$from, | ||
mustMove = sel.empty; | ||
if (sel instanceof TextSelection) { | ||
if (!unwrap(view).endOfTextblock(dirStr) || $start.depth == 0) | ||
return false; | ||
mustMove = false; | ||
$start = state.doc.resolve(dir > 0 ? $start.after() : $start.before()); | ||
} | ||
const found = GapCursor.findGapCursorFrom($start, dir, mustMove); | ||
if (!found) return false; | ||
if (dispatch) dispatch(state.tr.setSelection(found)); | ||
return true; | ||
}; | ||
} | ||
|
||
function mousedown(view: EditorView, event: MouseEvent) { | ||
const clickPos = view.posAtCoords({ | ||
left: event.clientX, | ||
top: event.clientY, | ||
}); | ||
if (!clickPos) return false; | ||
if (!view || !view.editable) return false; | ||
const $pos = resolvePosition(view, clickPos); | ||
|
||
if (!GapCursor.valid($pos)) return false; | ||
event.preventDefault(); | ||
view.dispatch(view.state.tr.setSelection(new GapCursor($pos))); | ||
return true; | ||
} | ||
|
||
/** | ||
* Helper function which takes in the result of `view.posAtCoords` and returns a resolved-position in the current document. | ||
* If the provided `pos` is not a direct child of the node at `inside`, this function tries to find a valid position that is a direct child of `inside`. | ||
*/ | ||
function resolvePosition( | ||
view: EditorView, | ||
{ pos, inside }: { pos: number; inside: number }, | ||
) { | ||
let result = view.state.doc.resolve(pos); | ||
const parent = inside === -1 ? view.state.doc : view.state.doc.nodeAt(inside); | ||
while (result.parent !== parent) { | ||
if (result.index() <= 0) { | ||
result = view.state.doc.resolve(result.before()); | ||
} else if (result.index() >= result.parent.childCount - 1) { | ||
result = view.state.doc.resolve(result.after()); | ||
} else { | ||
break; | ||
} | ||
} | ||
return result; | ||
} | ||
|
||
// This is a hack that, when a composition starts while a gap cursor | ||
// is active, quickly creates an inline context for the composition to | ||
// happen in, to avoid it being aborted by the DOM selection being | ||
// moved into a valid position. | ||
function beforeinput(view: EditorView, event: InputEvent) { | ||
if ( | ||
event.inputType != 'insertCompositionText' || | ||
!(view.state.selection instanceof GapCursor) | ||
) | ||
return false; | ||
|
||
const { $from } = view.state.selection; | ||
const insert = $from.parent | ||
.contentMatchAt($from.index()) | ||
.findWrapping(view.state.schema.nodes.text); | ||
if (!insert) return false; | ||
|
||
let frag = Fragment.empty; | ||
for (let i = insert.length - 1; i >= 0; i--) | ||
frag = Fragment.from(insert[i].createAndFill(null, frag)); | ||
const tr = view.state.tr.replace($from.pos, $from.pos, new Slice(frag, 0, 0)); | ||
tr.setSelection(TextSelection.near(tr.doc.resolve($from.pos + 1))); | ||
view.dispatch(tr); | ||
return false; | ||
} | ||
|
||
function drawGapCursor(state: EditorState) { | ||
if (!(state.selection instanceof GapCursor)) return null; | ||
const node = document.createElement('div'); | ||
node.className = 'ProseMirror-gapcursor'; | ||
return DecorationSet.create(state.doc, [ | ||
Decoration.widget(state.selection.head, node, { key: 'gapcursor' }), | ||
]); | ||
} |