-
Notifications
You must be signed in to change notification settings - Fork 2
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat(adapters): caret adapter basic implementation
- Loading branch information
Showing
8 changed files
with
357 additions
and
7 deletions.
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 |
---|---|---|
@@ -1,6 +1,10 @@ | ||
{ | ||
"eslint.workingDirectories": [ | ||
"./packages/dom-adapters", | ||
"./packages/model", | ||
"./packages/playground" | ||
"./packages/playground", | ||
], | ||
"cSpell.words": [ | ||
"Singletone" | ||
] | ||
} |
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,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, | ||
}, | ||
})); | ||
} | ||
} |
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,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; | ||
} |
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,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
51
packages/dom-adapters/src/caret/utils/useSelectionChange.ts
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,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, | ||
}; | ||
}); |
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,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> |
Oops, something went wrong.