Skip to content

Commit

Permalink
feat(adapters): caret adapter basic implementation
Browse files Browse the repository at this point in the history
  • Loading branch information
neSpecc committed Nov 18, 2023
1 parent 536bf8c commit 568c0b1
Show file tree
Hide file tree
Showing 8 changed files with 357 additions and 7 deletions.
6 changes: 5 additions & 1 deletion .vscode/settings.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
{
"eslint.workingDirectories": [
"./packages/dom-adapters",
"./packages/model",
"./packages/playground"
"./packages/playground",
],
"cSpell.words": [
"Singletone"
]
}
140 changes: 140 additions & 0 deletions packages/dom-adapters/src/caret/CaretAdapter.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
import type { EditorJSModel } from '@editorjs/model';
import { useSelectionChange, type Subscriber } from './utils/useSelectionChange.js';
import { getAbsoluteRangeOffset } from './utils/absoluteOffset.js';

/**
* Caret index is a tuple of start and end offset of a caret
*/
export type CaretIndex = [number, number];

/**
* Caret adapter watches input caret change and passes it to the model
*
* - subscribe on input caret change
* - compose caret index by selection position related to start of input
* - pass caret position to model — model.updateCaret()
* - subscribe on model's ‘caret change’ event (index) => {}), by filtering events only related with current input and set caret position
*
* @todo add support for native inputs
* @todo debug problem when document "selectionchange" is not fired on Enter press
* @todo debug problem when offset at the end of line and at the beginning of the next line is the same
*/
export class CaretAdapter extends EventTarget {
/**
* Index stores start and end offset of a caret depending on a root of input
*/
#index: CaretIndex = [0, 0];

/**
* Input element
*/
#input: null | HTMLElement = null;

/**
* Method for subscribing on document selection change
*/
#onSelectionChange: (callback: Subscriber) => void;

/**
* Method for unsubscribing from document selection change
*/
#offSelectionChange: (callback: Subscriber) => void;

/**
* Callback that will be called on document selection change
* Stored as a class property to be able to unsubscribe from document selection change
*
* @param selection - changed document selection
*/
#onDocumentSelectionChange = (selection: Selection | null): void => {
if (!this.#isSelectionRelatedToInput(selection)) {
return;
}

this.#updateIndex(selection);
};

/**
* @param model - EditorJSModel instance
* @param blockIndex - index of a block that contains input
*/
constructor(private readonly model: EditorJSModel, private readonly blockIndex: number) {
super();

const { on, off } = useSelectionChange();

this.#onSelectionChange = on;
this.#offSelectionChange = off;
}

/**
* - Subscribes on input caret change
* - Composes caret index by selection position related to start of input
*
* @param input - input to watch caret change
* @param dataKey - key of data property in block's data that contains input's value
*/
public attachInput(input: HTMLElement, dataKey: string): void {
this.#input = input;

this.#onSelectionChange(this.#onDocumentSelectionChange);
}

/**
* Unsubscribes from input caret change
*/
public detachInput(): void {
this.#offSelectionChange(this.#onDocumentSelectionChange);
}

/**
* Checks if selection is related to input
*
* @param selection - changed document selection
*/
#isSelectionRelatedToInput(selection: Selection | null): boolean {
if (!selection) {
return false;
}

const range = selection.getRangeAt(0);

return this.#input?.contains(range.startContainer) ?? false;
}

/**
* Returns absolute caret index related to input
*/
public get index(): CaretIndex {
return this.#index;
}

/**
* Updates caret index
*
* @param selection - changed document selection
*/
#updateIndex(selection: Selection | null): void {
const range = selection?.getRangeAt(0);

if (!range || !this.#input) {
return;
}

this.#index = [
getAbsoluteRangeOffset(this.#input, range.startContainer, range.startOffset),
getAbsoluteRangeOffset(this.#input, range.endContainer, range.endOffset),
];

/**
* @todo
*/
// this.#model.updateCaret(this.blockIndex, this.#index);

this.dispatchEvent(new CustomEvent('change', {
detail: {
index: this.#index,
},
}));
}
}
57 changes: 57 additions & 0 deletions packages/dom-adapters/src/caret/utils/absoluteOffset.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
/**
* Returns true if node is a line break
*
* <div>
* <div>
* <br>
* </div>
* </div>
*
* @param node - node to check
*/
function isLineBreak(node: Node): boolean {
return node.textContent?.length === 0 && node.nodeType === Node.ELEMENT_NODE && (node as Element).querySelector('br') !== null;
}

/**
* Returns absolute caret offset from caret position in container to the start of the parent node (input)
*
* @param parent - parent node containing the range
* @param initialNode - exact node containing the caret
* @param initialOffset - caret offset in the initial node
*/
export function getAbsoluteRangeOffset(parent: Node, initialNode: Node, initialOffset: number): number {
let node = initialNode;
let offset = initialOffset;

if (!parent.contains(node)) {
throw new Error('Range is not contained by the parent node');
}

while (node !== parent) {
const childNodes = Array.from(node.parentNode!.childNodes);
const index = childNodes.indexOf(node as ChildNode);

/**
* Iterate over left siblings and compute offset
*/
offset = childNodes.slice(0, index)
.reduce((acc, child) => {
/**
* Support for line breaks
*/
if (isLineBreak(child)) {
return acc + 1;
}

/**
* Compute offset with text length of left siblings
*/
return acc + child.textContent!.length;
}, offset);

node = node.parentNode!;
}

return offset;
}
25 changes: 25 additions & 0 deletions packages/dom-adapters/src/caret/utils/singletone.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
/**
* Creates a singletone factory.
*
* @example
* const useFoo = createSingletone(() => {
* const foo = 'bar';
*
* return {
* foo,
* };
* });
*
* @param factory - factory function that will be called only once
*/
export function createSingletone<T>(factory: () => T): () => T {
let instance: T | null = null;

return () => {
if (instance === null) {
instance = factory();
}

return instance;
};
}
51 changes: 51 additions & 0 deletions packages/dom-adapters/src/caret/utils/useSelectionChange.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import { createSingletone } from './singletone.js';

/**
* Singletone that watches for document "selection change" event and delegates the provided callbacks to subscribers.
*/
export interface Subscriber {
/**
* Callback that will be called on "selection change" event.
*
* @param selection - current document selection
*/
(selection: Selection | null): void;
}

/**
* const should contain a function that will return on and off methods.
*/
export const useSelectionChange = createSingletone(() => {
const subscribers = new Set<Subscriber>();

document.addEventListener('selectionchange', () => {
const selection = document.getSelection();

subscribers.forEach((callback) => {
callback(selection);
});
});

/**
* Subscribe on "selection change" event.
*
* @param callback - callback that will be called on "selection change" event
*/
function on(callback: Subscriber): void {
subscribers.add(callback);
}

/**
* Unsubscribe from "selection change" event.
*
* @param callback - callback that was passed to "on" method
*/
function off(callback: Subscriber): void {
subscribers.delete(callback);
}

return {
on,
off,
};
});
13 changes: 8 additions & 5 deletions packages/playground/src/App.vue
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
<script setup lang="ts">
import { Node } from './components';
import { EditorDocument } from '@editorjs/model';
import { Node, Input } from './components';
import { EditorDocument, EditorJSModel } from '@editorjs/model';
import { data } from '../../model/src/mocks/data.ts';
const document = new EditorDocument(data);
const model = new EditorJSModel(data);
console.log('document', document);
</script>

<template>
Expand All @@ -20,7 +20,10 @@ console.log('document', document);
Editor.js Document Playground
</div>
<div :class="$style.body">
<div :class="$style.input">
<div :class="$style.playground">
<Input
:model="model"
/>
<pre>{{ data }}</pre>
</div>
<div :class="$style.output">
Expand All @@ -45,7 +48,7 @@ console.log('document', document);
grid-gap: 16px;
}
.input {
.playground {
max-width: 100%;
overflow: auto;
font-size: 12px;
Expand Down
68 changes: 68 additions & 0 deletions packages/playground/src/components/Input.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
<script setup lang="ts">
import { Ref, onMounted, ref } from 'vue';
import { CaretAdapter, type CaretIndex } from '../../../dom-adapters/src/caret/CaretAdapter';
import { type EditorJSModel } from '@editorjs/model';
const input = ref<HTMLElement | null>(null);
const index = ref<CaretIndex>([0, 0]);
const props = defineProps<{
/**
* Editor js Document model to attach input to
*/
model: EditorJSModel;
}>();
onMounted(() => {
const adapter = new CaretAdapter(props.model, 0);
if (input.value !== null) {
adapter.attachInput(input.value, 'text');
adapter.addEventListener('change', (event) => {
index.value = (event as CustomEvent<{ index: CaretIndex}>).detail.index;
});
}
});
</script>
<template>
<div :class="$style.wrapper">
<!-- eslint-disable vue/no-v-html -->
<div
ref="input"
contenteditable
type="text"
:class="$style.input"
v-html="`Some words <b>inside</b> the input`"
/>
<div :class="$style.counter">
{{ index }}
</div>
</div>
</template>


<style module>
.wrapper {
position: relative;
}
.counter {
position: absolute;
top: 0;
right: 0;
padding: 8px 14px;
background-color: rgba(0, 0, 0, 0.2);
border-radius: 10px;
font-size: 22px;
}
.input {
padding: 8px 14px;
background-color: rgba(0, 0, 0, 0.2);
border-radius: 10px;
font-size: 22px;
outline: none;
}
</style>
Loading

0 comments on commit 568c0b1

Please sign in to comment.