Skip to content

Commit

Permalink
feat(text-editor): add insertHtml function to properly parse html string
Browse files Browse the repository at this point in the history
Co-authored-by: Adrian Schmidt <[email protected]>
  • Loading branch information
FredrikWallstrom and adrianschmidt committed Dec 4, 2024
1 parent 2a56e72 commit b69c475
Show file tree
Hide file tree
Showing 8 changed files with 279 additions and 54 deletions.
1 change: 1 addition & 0 deletions etc/lime-elements.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -2417,6 +2417,7 @@ export interface TabPanelComponent {
// @alpha (undocumented)
export interface TextEditor {
insert: (input: TextEditorNode | string) => void;
insertHtml: (input: string) => Promise<void>;
// (undocumented)
stopTrigger: () => void;
}
Expand Down
85 changes: 69 additions & 16 deletions src/components/text-editor/examples/text-editor-custom-triggers.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,8 +27,11 @@ import { TextEditor, TriggerEventDetail } from '../text-editor.types';
* supplied methods will effectivly replace the trigger content in the text editor with
* the content of choice.
*
* In this example we pass either a text or a `limel-chip` representing some chosen user
* in a mention like situation.
* In this example we pass either plain text, HTML string, or a `limel-chip`
* representing some chosen user in a mention like situation.
*
* :::note
* Changing the contentType resets the text editor with an empty value.
*/
@Component({
tag: 'limel-example-text-editor-triggers',
Expand All @@ -43,13 +46,16 @@ export class TextEditorCustomTriggersExample {
private triggerState: string = '';

@State()
private inputText: string = '';
private tagValue: string = '';

@State()
private isPickerOpen: boolean = false;

@State()
private insertMode: 'text' | 'chip' = 'text';
private insertMode: 'text' | 'chip' | 'html' = 'text';

@State()
private contentType: 'markdown' | 'html' = 'markdown';

@State()
private items: Array<MenuItem<number>> = [
Expand All @@ -76,6 +82,22 @@ export class TextEditorCustomTriggersExample {
id: '2',
title: 'chip',
},
{
id: '3',
title: 'html',
},
];

private contentTypeButtons: Button[] = [
{
id: '1',
title: 'markdown',
selected: true,
},
{
id: '2',
title: 'html',
},
];

private triggerFunction?: TextEditor;
Expand All @@ -85,11 +107,11 @@ export class TextEditorCustomTriggersExample {
this.setupEventHandlers();
}

@Watch('inputText')
protected watchInputText() {
@Watch('tagValue')
protected watchTagValue() {
if (this.isPickerOpen) {
this.visibleItems = this.items.filter((item: MenuItem<number>) =>
item.text.toLowerCase().includes(this.inputText),
item.text.toLowerCase().includes(this.tagValue),
);
}
}
Expand Down Expand Up @@ -180,6 +202,16 @@ export class TextEditorCustomTriggersExample {

public render() {
return [
<limel-example-controls>
<label>
contentType:
<limel-button-group
class="mode"
value={this.contentTypeButtons}
onChange={this.handleContentTypeChange}
/>
</label>
</limel-example-controls>,
this.renderPicker(),
<limel-text-editor
style={{ display: 'block' }}
Expand All @@ -192,22 +224,26 @@ export class TextEditorCustomTriggersExample {
onTriggerStop={this.handleTriggerStop}
onTriggerChange={this.handleTriggerChange}
onChange={this.handleChange}
contentType={this.contentType}
key={this.contentType}
/>,
<limel-example-controls>
Insert mode:
<limel-button-group
class="mode"
value={this.insertModeButtons}
onChange={this.handleInsertModeChange}
/>
<label>
Insert mode:
<limel-button-group
class="mode"
value={this.insertModeButtons}
onChange={this.handleInsertModeChange}
/>
</label>
<div class="value">
<limel-example-value
label="Action"
value={this.triggerState}
/>
<limel-example-value
label="Tag value"
value={this.inputText}
value={this.tagValue}
/>
</div>
</limel-example-controls>,
Expand Down Expand Up @@ -252,12 +288,12 @@ export class TextEditorCustomTriggersExample {

private handleTriggerStop = () => {
this.triggerState = 'stop';
this.inputText = '';
this.tagValue = '';
this.isPickerOpen = false;
};

private handleTriggerChange = (event: CustomEvent<TriggerEventDetail>) => {
this.inputText = event.detail.value.toLowerCase();
this.tagValue = event.detail.value.toLowerCase();
};

private handleChange = (event: CustomEvent<string>) => {
Expand All @@ -274,9 +310,26 @@ export class TextEditorCustomTriggersExample {
this.insertMode = event.detail.title as any;
};

private handleContentTypeChange = (event: CustomEvent<Button>) => {
this.contentType = event.detail.title as any;
this.value = '';
};

private insertItem = (item: MenuItem) => {
this.removeAllSelections();
this.visibleItems = this.items;

if (this.insertMode === 'html') {
this.triggerFunction
.insertHtml(`<strong>${item.text}</strong>`)
.then(() => console.log('HTML inserted successfully'))
.catch((error) =>
console.error('Error inserting HTML:', error),
);

return;
}

if (this.insertMode === 'text') {
this.triggerFunction.insert('@' + item.text);

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
import { Schema } from 'prosemirror-model';
import { createHtmlInserter } from './create-html-inserter';

describe('createHtmlInserter', () => {
let mockContentConverter: any;
let mockDispatchTransaction: jest.Mock;
let schema: Schema;

beforeEach(() => {
mockContentConverter = {
parseAsHTML: jest.fn((input) => Promise.resolve(`<p>${input}</p>`)),
};

mockDispatchTransaction = jest.fn();

schema = new Schema({
nodes: {
doc: { content: 'block+' },
paragraph: { group: 'block', content: 'inline*' },
text: { group: 'inline' },
},
marks: {},
});
});

it('resolves after inserting valid HTML into the editor', async () => {
const inserter = await createHtmlInserter(
{ state: { schema: schema } } as any, // Mock EditorView
mockContentConverter,
0, // startPos
mockDispatchTransaction,
);

const inputHtml = '<strong>Test</strong>';
await inserter(inputHtml);

expect(mockContentConverter.parseAsHTML).toHaveBeenCalledWith(
inputHtml,
schema,
);
expect(mockDispatchTransaction).toHaveBeenCalledTimes(1);
});

it('resolves after handling invalid HTML gracefully', async () => {
const inserter = await createHtmlInserter(
{ state: { schema: schema } } as any,
mockContentConverter,
0,
mockDispatchTransaction,
);

const inputHtml = '<div><p>Unclosed tag';
await inserter(inputHtml);

expect(mockContentConverter.parseAsHTML).toHaveBeenCalledWith(
inputHtml,
schema,
);
expect(mockDispatchTransaction).toHaveBeenCalledTimes(1);
});

it('dispatches the correct fragment for nested HTML', async () => {
const inserter = await createHtmlInserter(
{ state: { schema: schema } } as any,
mockContentConverter,
0,
mockDispatchTransaction,
);

const inputHtml = '<div><span color="#FF0000">Nested</span></div>';
await inserter(inputHtml);

expect(mockDispatchTransaction).toHaveBeenCalledTimes(1);

// Convert the fragment to an array for easier testing
const dispatchedArgs = mockDispatchTransaction.mock.calls[0];
const dispatchedFragment = dispatchedArgs[2];
const fragmentArray = dispatchedFragment.content.map((node) =>
node.toJSON(),
);

// Check the structure and content of the fragment
// The reason that the structure doesn't completely match the input is
// that ProseMirror will transform the input based on what the schema
// allows. (At least I think that's why… /Ads)
expect(fragmentArray).toEqual([
{
type: 'paragraph',
content: [
{
type: 'text',
text: 'Nested',
},
],
},
]);
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { Node, DOMParser, Fragment } from 'prosemirror-model';
import { EditorView } from 'prosemirror-view';
import { ContentTypeConverter } from '../../../utils/content-type-converter';

export const createHtmlInserter = (
view: EditorView,
contentConverter: ContentTypeConverter,
startPos: number,
dispatchTransaction: (
view: EditorView,
startPos: number,
fragment: Fragment | Node,
) => void,
): ((input: string) => Promise<void>) => {
const schema = view.state.schema;

return async (input: string): Promise<void> => {
const container = document.createElement('span');
container.innerHTML = await contentConverter.parseAsHTML(input, schema);

const fragment = DOMParser.fromSchema(schema).parse(container).content;

dispatchTransaction(view, startPos, fragment);
};
};
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
TriggerCharacter,
TriggerEventDetail,
} from 'src/components/text-editor/text-editor.types';
import { ContentTypeConverter } from '../../../utils/content-type-converter';

const isTrigger = (
key: string,
Expand Down Expand Up @@ -61,24 +62,26 @@ const stillHasTrigger = (

const getTriggerEventDetail = (
view: EditorView,
contentConverter: ContentTypeConverter,
trigger: TriggerCharacter,
value: string,
): TriggerEventDetail => {
return {
trigger: trigger,
textEditor: inserterFactory(view),
textEditor: inserterFactory(view, contentConverter),
value: value,
};
};

const sendTriggerEvent = (
type: 'triggerStart' | 'triggerStop' | 'triggerChange',
view: EditorView,
contentConverter: ContentTypeConverter,
trigger: TriggerCharacter,
value: string,
) => {
const event = new CustomEvent<TriggerEventDetail>(type, {
detail: getTriggerEventDetail(view, trigger, value),
detail: getTriggerEventDetail(view, contentConverter, trigger, value),
bubbles: true,
composed: true,
});
Expand Down Expand Up @@ -129,15 +132,24 @@ const processTransactions = (
return text;
};

export const createTriggerPlugin = (triggerCharacters: TriggerCharacter[]) => {
export const createTriggerPlugin = (
triggerCharacters: TriggerCharacter[],
contentConverter: ContentTypeConverter,
) => {
let activeTrigger: TriggerCharacter | null = null;
let triggerText = '';
let pluginView: EditorView | null = null;
let triggerPosition: number | null = null;

const stopTrigger = () => {
triggerText = '';
sendTriggerEvent('triggerStop', pluginView, activeTrigger, triggerText);
sendTriggerEvent(
'triggerStop',
pluginView,
contentConverter,
activeTrigger,
triggerText,
);
triggerPosition = null;
activeTrigger = null;
};
Expand All @@ -163,7 +175,13 @@ export const createTriggerPlugin = (triggerCharacters: TriggerCharacter[]) => {
activeTrigger = event.data;
triggerText = '';
triggerPosition = state.selection.$from.pos - triggerText.length;
sendTriggerEvent('triggerStart', view, activeTrigger, triggerText);
sendTriggerEvent(
'triggerStart',
view,
contentConverter,
activeTrigger,
triggerText,
);

return false;
}
Expand Down Expand Up @@ -202,6 +220,7 @@ export const createTriggerPlugin = (triggerCharacters: TriggerCharacter[]) => {
sendTriggerEvent(
'triggerChange',
pluginView,
contentConverter,
activeTrigger,
triggerText.slice(1),
);
Expand Down
Loading

0 comments on commit b69c475

Please sign in to comment.