Skip to content

Commit

Permalink
feat(text editor mentions): prototype trigger plugin with querystring…
Browse files Browse the repository at this point in the history
… builder
  • Loading branch information
john-traas committed Aug 26, 2024
1 parent 6e6fac5 commit 41035ce
Show file tree
Hide file tree
Showing 4 changed files with 282 additions and 2 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { MarkSpec } from 'prosemirror-model';

export const mentionTagMark: MarkSpec = {
attrs: {
type: { default: 'mention' },
},
inclusive: false,
parseDOM: [
{
tag: 'span[data-type]',
getAttrs: (dom) => ({
type: dom.getAttribute('data-type'),
}),
},
],
toDOM: (node) => ['span', { 'data-type': node.attrs.type }, 0],
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import { NodeSpec } from 'prosemirror-model';

export const mention: NodeSpec = {
inline: true,
group: 'inline',
selectable: true,
atom: true, // Makes the node behave as a single unit

attrs: {
type: {},
id: {},
name: {},
},

toDOM: (node) => [
'span',
{
class: 'mention',
'data-type': node.attrs.type,
'data-id': node.attrs.id,
'data-name': node.attrs.name,
},
'@' + node.attrs.name,
],
parseDOM: [
{
tag: 'span[data-type][data-id][data-name]',
getAttrs: (dom) => ({
type: dom.getAttribute('data-type'),
id: dom.getAttribute('data-id'),
name: dom.getAttribute('data-name'),
}),
},
],
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
import { Plugin, PluginKey, EditorState } from 'prosemirror-state';
import { ReplaceStep, ReplaceAroundStep } from 'prosemirror-transform';
import { EditorView } from 'prosemirror-view';

export const triggerPluginKey = new PluginKey('triggerPlugin');

type Trigger = '@' | '#';

const triggers: Trigger[] = ['@', '#'];

const isTrigger = (key: string): key is Trigger => {
return triggers.includes(key as Trigger);
};

const shouldTrigger = (state: EditorState): boolean => {
const { $from, $to } = state.selection;

const prevPos = $from.pos - 1;

if (!prevPos) {
return true;
}

if ($from === $to) {
const prevChar = state.doc.textBetween(prevPos, $from.pos);

return prevChar === ' ';
}

return false;
};

const openPicker = (view: EditorView) => {
const event = new CustomEvent<void>('open-picker', {
bubbles: true,
composed: true,
});
view.dom.dispatchEvent(event);
};

const closePicker = (view: EditorView) => {
const event = new CustomEvent<void>('close-picker', {
bubbles: true,
composed: true,
});
view.dom.dispatchEvent(event);
};

export const createTriggerPlugin = () => {
let activeTrigger: Trigger | null = null; // Track the active trigger type
let queryString = ''; // queryString to track the input after a trigger

return new Plugin({
key: triggerPluginKey,
props: {
handleKeyDown: (view, event) => {
const { state } = view;
const { selection } = state;
const { $from } = selection;

Check failure on line 59 in src/components/text-editor/prosemirror-adapter/plugins/trigger-popup-plugin.ts

View workflow job for this annotation

GitHub Actions / Lint

'$from' is assigned a value but never used. Allowed unused vars must match /^h$/u

if (event.key === 'Escape') {
closePicker(view);
activeTrigger = null; // Reset the active trigger
queryString = ''; // Clear the queryString

return true;
}

if (isTrigger(event.key) && shouldTrigger(state)) {
activeTrigger = event.key;
queryString = ''; // Reset queryString when a new trigger starts
openPicker(view);

return false;
} else if (
activeTrigger &&
(event.key === ' ' ||
event.key === 'Enter' ||
event.key === 'Tab')
) {
// End of mention/tag
activeTrigger = null;
closePicker(view);
queryString = ''; // Clear the queryString

return false;
}

return false;
},
},
appendTransaction: (transactions, oldState, newState) => {

Check failure on line 92 in src/components/text-editor/prosemirror-adapter/plugins/trigger-popup-plugin.ts

View workflow job for this annotation

GitHub Actions / Lint

'oldState' is defined but never used

Check failure on line 92 in src/components/text-editor/prosemirror-adapter/plugins/trigger-popup-plugin.ts

View workflow job for this annotation

GitHub Actions / Lint

'newState' is defined but never used
if (activeTrigger) {
let textAdded = '';

transactions.forEach((transaction) => {
if (transaction.docChanged) {
transaction.steps.forEach((step) => {
if (
step instanceof ReplaceStep ||
step instanceof ReplaceAroundStep
) {
const slice = step.slice;
if (slice && slice.size) {
textAdded += slice.content.textBetween(
0,
slice.size,
);
}
}
});
}
});

if (textAdded) {
queryString += textAdded;
console.log('Captured input:', queryString);

Check failure on line 117 in src/components/text-editor/prosemirror-adapter/plugins/trigger-popup-plugin.ts

View workflow job for this annotation

GitHub Actions / Lint

Unexpected console statement
// Update mention/tag suggestions here using the queryString
}
}

return null; // No new transaction is returned
},
});
};
107 changes: 105 additions & 2 deletions src/components/text-editor/prosemirror-adapter/prosemirror-adapter.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ import { addListNodes } from 'prosemirror-schema-list';
import { exampleSetup } from 'prosemirror-example-setup';
import { keymap } from 'prosemirror-keymap';
import { ActionBarItem } from 'src/components/action-bar/action-bar.types';
import { ListSeparator } from 'src/components/list/list-item.types';
import { ListItem, ListSeparator } from 'src/components/list/list-item.types';
import { MenuCommandFactory } from './menu/menu-commands';
import { menuTranslationIDs, getTextEditorMenuItems } from './menu/menu-items';
import { ContentTypeConverter } from '../utils/content-type-converter';
Expand All @@ -40,6 +40,9 @@ import {
import { createImageRemoverPlugin } from './plugins/image-remover-plugin';
import { createMenuStateTrackingPlugin } from './plugins/menu-state-tracking-plugin';
import { createActionBarInteractionPlugin } from './plugins/menu-action-interaction-plugin';
import { createTriggerPlugin } from './plugins/trigger-popup-plugin';
import { LimelPickerCustomEvent } from '../../../../src/index';
import { mention } from './mentions/node-schema-extender';

/**
* The ProseMirror adapter offers a rich text editing experience with markdown support.
Expand Down Expand Up @@ -98,6 +101,31 @@ export class ProsemirrorAdapter {
@State()
public isLinkMenuOpen: boolean = false;

/**
* Open state of the picker
*/
@State()
public isPickerOpen: boolean = false;

@State()
private selectedItem: ListItem<number>;

private fakeMentionItems: Array<ListItem<number>> = [
{ text: 'Admiral Swiggins', value: 1 },
{ text: 'Ayla', value: 2 },
{ text: 'Clunk', value: 3 },
{ text: 'Coco', value: 4 },
{ text: 'Derpl', value: 5 },
{ text: 'Froggy G', value: 6 },
{ text: 'Gnaw', value: 7 },
{ text: 'Lonestar', value: 8 },
{ text: 'Leon', value: 9 },
{ text: 'Raelynn', value: 10 },
{ text: 'Skølldir', value: 11 },
{ text: 'Voltar', value: 12 },
{ text: 'Yuri', value: 13 },
];

private menuCommandFactory: MenuCommandFactory;
private schema: Schema;
private contentConverter: ContentTypeConverter;
Expand Down Expand Up @@ -153,13 +181,18 @@ export class ProsemirrorAdapter {
'open-editor-link-menu',
this.handleOpenLinkMenu,
);

this.host.addEventListener('open-picker', this.handleOpenPicker);
this.host.addEventListener('close-picker', this.handleClosePicker);
}

public disconnectedCallback() {
this.host.removeEventListener(
'open-editor-link-menu',
this.handleOpenLinkMenu,
);
this.host.removeEventListener('open-picker', this.handleOpenPicker);
this.host.removeEventListener('close-picker', this.handleClosePicker);
this.view.destroy();
}

Expand All @@ -175,6 +208,7 @@ export class ProsemirrorAdapter {
/>
</div>,
this.renderLinkMenu(),
this.renderPicker(),
];
}

Expand Down Expand Up @@ -202,6 +236,66 @@ export class ProsemirrorAdapter {
);
}

renderPicker() {
console.log('renderPicker');

Check failure on line 240 in src/components/text-editor/prosemirror-adapter/prosemirror-adapter.tsx

View workflow job for this annotation

GitHub Actions / Lint

Unexpected console statement
if (!this.isPickerOpen) {
return;
}

console.log('picker is open');

Check failure on line 245 in src/components/text-editor/prosemirror-adapter/prosemirror-adapter.tsx

View workflow job for this annotation

GitHub Actions / Lint

Unexpected console statement

return (
<limel-portal
containerId={this.portalId}
visible={this.isPickerOpen}
openDirection="top"
inheritParentWidth={true}
anchor={this.actionBarElement}
>
<limel-picker
label="Favorite awesomenaut"
value={this.selectedItem}
searcher={this.search}
onChange={this.onChange}
onInteract={this.onInteract}
/>
</limel-portal>
);
}

private handleClosePicker = (event: CustomEvent<void>) => {
event.stopImmediatePropagation();
this.isPickerOpen = false;
};

private handleOpenPicker = (event: CustomEvent<string>) => {
event.stopImmediatePropagation();
this.isPickerOpen = true;
console.log('open picker', this.isPickerOpen);

Check failure on line 274 in src/components/text-editor/prosemirror-adapter/prosemirror-adapter.tsx

View workflow job for this annotation

GitHub Actions / Lint

Unexpected console statement
};

private search = (query: string): Promise<ListItem[]> => {
return new Promise((resolve) => {
if (query === '') {
return resolve(this.fakeMentionItems);
}

const filteredItems = this.fakeMentionItems.filter((item) => {
return item.text.toLowerCase().includes(query.toLowerCase());
});

return resolve(filteredItems);
});
};

private onChange = (event: LimelPickerCustomEvent<ListItem<number>>) => {
this.selectedItem = event.detail;
};

private onInteract = (event: LimelPickerCustomEvent<ListItem<number>>) => {
console.log('Value interacted with:', event.detail);

Check failure on line 296 in src/components/text-editor/prosemirror-adapter/prosemirror-adapter.tsx

View workflow job for this annotation

GitHub Actions / Lint

Unexpected console statement
};

private setupContentConverter() {
if (this.contentType === 'markdown') {
this.contentConverter = new markdownConverter();
Expand Down Expand Up @@ -252,8 +346,16 @@ export class ProsemirrorAdapter {
}

private initializeSchema() {
const nodes = schema.spec.nodes
.append({
mention: mention,
})
.append(
addListNodes(schema.spec.nodes, 'paragraph block*', 'block'),
);

return new Schema({
nodes: addListNodes(schema.spec.nodes, 'paragraph block*', 'block'),
nodes: nodes,
marks: schema.spec.marks.append({
strikethrough: strikethrough,
}),
Expand Down Expand Up @@ -290,6 +392,7 @@ export class ProsemirrorAdapter {
this.updateActiveActionBarItems,
),
createActionBarInteractionPlugin(this.menuCommandFactory),
createTriggerPlugin(),
],
});
}
Expand Down

0 comments on commit 41035ce

Please sign in to comment.