Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Layout #11

Draft
wants to merge 20 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions lib/components/HeadingDrawer.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import {h, app, text, type Action, type VNode, type CustomPayloads} from 'hyperapp';

type HeadingDrawerProps = CustomPayloads<any, {
levels: number[];
onSelect: Action<any, number>;
}>;

// eslint-disable-next-line @typescript-eslint/naming-convention
export const HeadingDrawer = ({levels = [1, 2, 3], onSelect}: HeadingDrawerProps) => {
if (!onSelect) {
throw new Error('onSelect action must be provided to HeadingDrawer component.');
}

return h('ul', {}, levels.map(level =>
h('li', {onclick: [onSelect, level]}, text(`Heading ${level}`)),
));
};

// eslint-disable-next-line @typescript-eslint/naming-convention
export const HeadingButton = (props: CustomPayloads<any, any>) => {
const onclick = (state: any): any => ({...state, active: state.active === 'heading' ? '' : 'heading'});

return h('button', {class: 'toolbar-button heading', onclick}, [
// eslint-disable-next-line new-cap
h('div', {style: {display: props.active === 'heading' ? 'block' : 'none'}}, HeadingDrawer(props)),
]);
};
Empty file added lib/editor.ts
Empty file.
149 changes: 136 additions & 13 deletions lib/index.ts
Original file line number Diff line number Diff line change
@@ -1,43 +1,136 @@
import {defaultKeymap} from '@codemirror/commands';
/**
* PhotonEditor
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at:
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License
*/

import {defaultKeymap, history} from '@codemirror/commands';
import {EditorState} from '@codemirror/state';
import {keymap, EditorView} from '@codemirror/view';
import {markdown} from '@codemirror/lang-markdown';
import {syntaxHighlighting} from '@codemirror/language';
import mitt, {type Emitter} from 'mitt';
import {app, h, type Dispatch} from 'hyperapp';

import markdownHighlight from './highlight/markdown';
import {type LayoutInterface, DefaultLayout} from './layout';
import {type MarkdownParserInterface, MarkdownParser} from './parser';
import {defaultButtonTypes, createDefaultButtonListener} from './listener';

type Options = {
value: string | undefined;
previewClass?: string;
editorContainerClass?: string;
photonEditorClass?: string;
};

/**
* Represents the main PhotonEditor class.
*/
class PhotonEditor {
private readonly emitter: Emitter<any>;
private editor: EditorView | undefined;
private previewAppDispatch: Dispatch<any> | undefined;
private readonly layout: LayoutInterface;
private readonly parser: MarkdownParserInterface;

/**
* Creates a new instance of PhotonEditor.
*
* @param {HTMLElement} element - The DOM element to attach the editor to.
* @param {Options} options - An object containing initial editor options.
* @param {LayoutInterface} [layout] - An optional custom layout object.
*/
constructor(
private readonly element: HTMLElement,
private readonly options: Options,
parser?: MarkdownParserInterface,
layout?: LayoutInterface,
) {
this.element = element;
this.options = options;
this.emitter = mitt();

this.layout = layout ?? new DefaultLayout(this.emitter, this.element);
this.parser = parser ?? new MarkdownParser();
}

on(eventName: string, callback: (event: any) => void) {
this.emitter.on(eventName, callback);
}

getEmitter(): Emitter<any> {
return this.emitter;
}

/**
* Creates the editor within the specified container element.
*/
createEditor() {
this.editor = new EditorView({
state: EditorState.create({
doc: this.options.value,
extensions: [
markdown(),
keymap.of(defaultKeymap),
syntaxHighlighting(markdownHighlight),
],
}),
parent: this.element,
this.waitForContainerReady(editorContainer => {
this.editor = new EditorView({
state: EditorState.create({
doc: this.options.value,
extensions: [
markdown(),
keymap.of(defaultKeymap),
history(),
syntaxHighlighting(markdownHighlight),
EditorView.updateListener.of(this.handleEditorUpdate.bind(this)),
],
}),
parent: editorContainer,
});

for (const buttonType of defaultButtonTypes) {
this.emitter.on(`toolbarButton:${buttonType}:clicked`, createDefaultButtonListener(buttonType, this.editor));
}
});

this.layout.render({
previewClass: this.options.previewClass,
editorContainerClass: this.options.editorContainerClass,
photonEditorClass: this.options.photonEditorClass,
});

const previewContainer = this.layout.getPreviewContainer();
if (previewContainer) {
this.previewAppDispatch = app({
init: {
markdown: this.getValue() ?? '',
},
view: state => h('div', {}, this.parser.parse(state.markdown)),
node: previewContainer,
dispatch: dispatch => {
this.previewAppDispatch = dispatch;
return dispatch;
},
});
}
}

/**
* Returns the editor's current value as a string.
*
* @returns {string | undefined}
*/
getValue() {
return this.editor?.state.doc.toString();
}

/**
* Sets the editor's value given a new input value.
*
* @param {string} value - The content to set as the new editor value.
*/
setValue(value: string) {
this.editor?.dispatch({
changes: {
Expand All @@ -47,6 +140,36 @@ class PhotonEditor {
},
});
}

/**
* Waits for the editor's container DOM element to become available, then
* executes a callback function on it.
*
* @private
* @param {(element: HTMLElement) => void} callback - The callback function for the ready event.
*/
private waitForContainerReady(callback: (element: HTMLElement) => void) {
const observer = new MutationObserver(() => {
const editorContainer = this.layout.getEditorContainer();

if (editorContainer) {
observer.disconnect();
callback(editorContainer);
}
});

observer.observe(this.element, {childList: true, subtree: true});
}

private updatePreviewState(state: any, markdown: string): any {
return {...state, markdown};
}

private async handleEditorUpdate(update: any) {
if (update.changes.length && this.previewAppDispatch) {
this.previewAppDispatch(this.updatePreviewState, this.getValue() ?? '');
}
}
}

export default PhotonEditor;
181 changes: 181 additions & 0 deletions lib/layout.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,181 @@
import {h, app, text, type VNode, type CustomPayloads} from 'hyperapp';
import {type Emitter} from 'mitt';

import {HeadingButton} from './components/HeadingDrawer';

import './styles/default.css';

type ToolbarItem = string | {
component: ((props: CustomPayloads<any, any>) => VNode<any>);
props: CustomPayloads<any, any>;
};

type Options = {
previewClass?: string;
editorContainerClass?: string;
editorPreviewContainerClass?: string;
photonEditorClass?: string;
toolbarItems?: ToolbarItem[][];
};

export type LayoutInterface = {
emitter: Emitter<any>;

render(options: Options): void;
getEditorContainer(): HTMLElement | undefined;
getPreviewContainer(): HTMLDivElement | undefined;
};

const defaultOptions: Options = {
previewClass: 'preview',
editorContainerClass: 'editor-container',
editorPreviewContainerClass: 'editor-preview-container',
photonEditorClass: 'photon-editor',
toolbarItems: [
[
{component: HeadingButton, props: {levels: [1, 2, 3, 4, 5, 6], onSelect(ev: any) {
console.log(ev);
}}},
'bold',
'italic',
'strike',
],
['hr', 'quote'],
['ul', 'ol', 'task'],
['table', 'image', 'link'],
['code', 'codeblock'],
],
};

export class DefaultLayout implements LayoutInterface {
private rootElement: HTMLDivElement | undefined;
private editorContainer: HTMLElement | undefined;
private previewContainer: HTMLDivElement | undefined;
private editorPreviewContaienr: HTMLDivElement | undefined;
private toolbarContainer: HTMLDivElement | undefined;

constructor(readonly emitter: Emitter<any>, private readonly parentElement: HTMLElement) {
this.emitter = emitter;
this.parentElement = parentElement;
}

getEditorContainer() {
return this.editorContainer;
}

getPreviewContainer() {
return this.previewContainer;
}

createRootElement(options: Options) {
this.rootElement = document.createElement('div');
if (options.photonEditorClass) {
this.rootElement.classList.add(options.photonEditorClass);
}

this.parentElement.appendChild(this.rootElement);
}

createEditorPreviewContainer(rootElement: HTMLDivElement, options: Options) {
this.editorPreviewContaienr = document.createElement('div');
if (options.editorPreviewContainerClass) {
this.editorPreviewContaienr.classList.add(options.editorPreviewContainerClass);
}

rootElement.appendChild(this.editorPreviewContaienr);
}

createEditorContainer(rootElement: HTMLDivElement, options: Options) {
this.editorContainer = document.createElement('div');
if (options.editorContainerClass) {
this.editorContainer.classList.add(options.editorContainerClass);
}

rootElement.appendChild(this.editorContainer);
}

createPreviewElement(rootElement: HTMLDivElement, options: Options) {
this.previewContainer = document.createElement('div');
if (options.previewClass) {
this.previewContainer.classList.add(options.previewClass);
}

rootElement.appendChild(this.previewContainer);
}

createToolbarContainer(rootElement: HTMLDivElement) {
this.toolbarContainer = document.createElement('div');
this.toolbarContainer.classList.add('toolbar');
rootElement.insertBefore(this.toolbarContainer, rootElement.firstChild);
}

initializeToolbar(options: Options) {
if (!this.toolbarContainer) {
return;
}

const toolbarComponent = (props: CustomPayloads<any, any>) => {
const toolbarItems = options.toolbarItems ?? [];
const toolbarChildren: Array<VNode<any>> = [];

for (const itemGroup of toolbarItems) {
const groupChildren: Array<VNode<any>> = [];

for (const item of itemGroup) {
if (typeof item === 'string') {
const buttonNode = h(
'button',
{
class: `toolbar-button ${item}`,
onclick: () => {
this.emitter.emit(`toolbarButton:${item}:clicked`);
},
},
[],
);
groupChildren.push(buttonNode);
} else {
groupChildren.push(item.component({...props, ...item.props}));
}
}

toolbarChildren.push(h('div', {}, groupChildren));
}

return toolbarChildren;
};

app({
init: {
active: '',
params: {
},
},
view: state => h('div', {}, toolbarComponent(state)),
node: this.toolbarContainer,
});
}

render(options: Partial<Options> = {}) {
options = {
previewClass: options.previewClass ?? defaultOptions.previewClass,
editorContainerClass: options.editorContainerClass ?? defaultOptions.editorContainerClass,
photonEditorClass: options.photonEditorClass ?? defaultOptions.photonEditorClass,
editorPreviewContainerClass: options.editorPreviewContainerClass ?? defaultOptions.editorPreviewContainerClass,
toolbarItems: options.toolbarItems ?? defaultOptions.toolbarItems,
};

this.createRootElement(options);
if (this.rootElement) {
this.createEditorPreviewContainer(this.rootElement, options);

this.createToolbarContainer(this.rootElement);
this.initializeToolbar(options);
}

if (this.editorPreviewContaienr) {
this.createEditorContainer(this.editorPreviewContaienr, options);
this.createPreviewElement(this.editorPreviewContaienr, options);
}
}
}
Loading