Skip to content

Commit

Permalink
Merge pull request #1027 from lblod/feature/custom-gap-cursor
Browse files Browse the repository at this point in the history
GN-4587: custom gap-cursor plugin
  • Loading branch information
abeforgit authored Nov 14, 2023
2 parents 951fe78 + 80613bc commit 23ee6ec
Show file tree
Hide file tree
Showing 4 changed files with 356 additions and 1 deletion.
9 changes: 9 additions & 0 deletions .changeset/gold-toys-smoke.md
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.
3 changes: 2 additions & 1 deletion addon/core/say-editor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@ import {
} from '@lblod/ember-rdfa-editor/utils/_private/dom-helpers';

import { v4 as uuidv4 } from 'uuid';
import { gapCursor } from 'prosemirror-gapcursor';
import { keymap } from 'prosemirror-keymap';
import { history } from 'prosemirror-history';
import {
Expand All @@ -34,6 +33,8 @@ import {
import SayView from '@lblod/ember-rdfa-editor/core/say-view';
import SayController from '@lblod/ember-rdfa-editor/core/say-controller';
import SaySerializer from '@lblod/ember-rdfa-editor/core/say-serializer';
import { gapCursor } from '../plugins/gap-cursor';

export type PluginConfig = Plugin[] | { plugins: Plugin[]; override?: boolean };
interface SayEditorArgs {
owner: Owner;
Expand Down
190 changes: 190 additions & 0 deletions addon/plugins/gap-cursor/gap-cursor.ts
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;
}
155 changes: 155 additions & 0 deletions addon/plugins/gap-cursor/index.ts
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' }),
]);
}

0 comments on commit 23ee6ec

Please sign in to comment.