import { uniqueIdGenerator } from 'lupine.web';
import { LJ_SVG_ICON_CLASS, SvgIconNames, loadSvgIconStyles } from '../svg-icons';
export type HEditorPickupImageHookProps = {
callback: (url: string) => void;
};
export type HEditorPluginButonProps = {
name: string;
label: string;
command?: string;
width?: string;
list?: { [key: string]: string };
style?: string;
};
export type HEditorPluginProps = {
name: string;
buttons: {
[key: string]: HEditorPluginButonProps;
};
onSelectedChanged?: (
editor: any,
plugin: HEditorPluginProps,
btnid: string,
selText: string,
selValue: string
) => void;
onButtonClick?: (editor: any, plugin: HEditorPluginProps, btnid: string) => void;
};
export type HEditorLangProps = { [key: string]: string };
export type HEditorLabelProps = { [key: string]: string };
export const HEditorLangEn: HEditorLangProps = {
name: 'EN',
Size: 'Size',
fontsize: 'Font Size',
bold: 'Bold',
italic: 'Italic',
underline: 'Underline',
left: 'Left Align',
center: 'Center Align',
right: 'Right Align',
justify: 'Justify Align',
ol: 'Insert Ordered List',
ul: 'Insert Unordered List',
strikethrough: 'Strike Through',
indent: 'Indent Text',
outdent: 'Remove Indent',
removeformat: 'Remove Formatting',
textColor: 'Text Color',
backColor: 'Background Color',
image: 'Image',
undo: 'Undo',
redo: 'Redo',
Link: 'Edit Link',
UnLink: 'Remove Link',
OK: 'OK',
Cancel: 'Cancel',
};
export const HEditorLangCn: HEditorLangProps = {
name: 'CN',
Size: '字号',
fontsize: '字号',
bold: '粗体',
italic: '斜体',
underline: '下划线',
left: '左对齐',
center: '居中对齐',
right: '右对齐',
justify: '两端对齐',
ol: '插入有序列表',
ul: '插入无序列表',
strikethrough: '删除线',
indent: '缩进',
outdent: '取消缩进',
removeformat: '清除格式',
textColor: '文本颜色',
backColor: '背景颜色',
image: '图片',
undo: '撤销',
redo: '重做',
Link: '编辑链接',
UnLink: '移除链接',
OK: '确定',
Cancel: '取消',
};
const HEditorLabel: HEditorLabelProps = {};
export const hEditorUniqueId = uniqueIdGenerator('he-'); // he means heditor
export class HEditor {
static _plugins: { [key: string]: HEditorPluginProps } = {};
static _lang: HEditorLangProps = HEditorLangEn;
static _label: HEditorLabelProps = HEditorLabel;
static iconColor = '#1384d1';
static _PickupImageHook: (callback: (url: string) => void) => void;
selector: string | HTMLDivElement;
m_buttons?: string[] = [];
constructor(selector: string | HTMLDivElement, html?: string, m_buttons?: string[], lang?: HEditorLangProps) {
this.selector = selector;
this.m_buttons = m_buttons;
HEditor._lang = lang || HEditor._lang;
this.init(html);
(window as any).HEditor = HEditor;
}
static setLang(lang: HEditorLangProps) {
HEditor._lang = lang;
}
static setLabel(label: HEditorLabelProps) {
HEditor._label = label;
}
static setIconColor(color: string) {
HEditor.iconColor = color;
}
static setPickupImageHook(hook: (callback: (url: string) => void) => void) {
HEditor._PickupImageHook = hook;
}
static lang(key: string) {
return HEditor._lang[key] || key;
}
static label(key: string) {
return HEditor._label[key] || key;
}
static addPlugin(name: string, plugin: HEditorPluginProps) {
HEditor._plugins[name.toUpperCase()] = plugin;
}
static getEditor(
selector: string | HTMLDivElement,
html?: string,
m_buttons?: string[],
lang?: HEditorLangProps
): HEditor | undefined {
const dom = typeof selector === 'string' ? document.querySelector(selector) : selector;
if (!dom) {
console.warn(`HEditor: ${selector} not found`);
return;
}
const domAny = dom as any;
if (domAny._edt) {
return domAny._edt;
}
const edt = new HEditor(selector, html, m_buttons, lang);
domAny._edt = edt;
return edt;
}
static getEditorById(eid: string): HEditor | undefined {
const dom = document.querySelector(`[_eid="${eid}"]`) as any;
if (!dom || !dom._edt) {
console.warn(`HEditor: ${eid} not found`);
return;
}
return dom._edt;
}
static processSelectedChanged(obj: HTMLSelectElement, edtId: string, pluginname: string, btnid: string) {
const editor = HEditor.getEditorById(edtId);
const plugin = HEditor._plugins[pluginname.toUpperCase()];
plugin.onSelectedChanged!(editor, plugin, btnid, obj.options[obj.selectedIndex].text, obj.value);
obj.selectedIndex = 0;
}
static processButtonClick(obj: HTMLSelectElement, edtId: string, pluginname: string, btnid: string) {
const editor = HEditor.getEditorById(edtId);
const plugin = HEditor._plugins[pluginname.toUpperCase()];
plugin.onButtonClick!(editor, plugin, btnid);
}
m_id: string = '';
m_doc: any;
m_win: any;
m_p: any;
m_ifrm: any;
m_focus = false;
m_range: any;
m_savedRange: Range | null = null;
m_drag: any;
m_pickingColor = false;
onBlur() {
this.m_focus = false;
}
onFocus() {
this.m_focus = true;
this.m_range = null;
}
onMDown(e: MouseEvent) {
if (this.m_pickingColor) {
e.preventDefault();
this.m_pickingColor = false;
}
this.m_focus = true;
const tg = e.target as HTMLElement;
const tgname = tg.nodeName.toUpperCase();
if (this.m_drag && this.m_drag.o) {
// this.m_drag.o.style.opacity = this.m_drag.op;
// this.m_drag.o.style.border = this.m_drag.b;
this.m_drag.o.style.cursor = this.m_drag.c;
this.m_drag = null;
}
if (tgname == 'IMG') {
this.selObj(tg);
this.m_drag = {
ty: -1,
o: tg,
x: e.clientX,
y: e.clientY,
w: tg.offsetWidth,
h: tg.offsetHeight,
// op: tg.style.opacity,
// b: tg.style.border,
c: tg.style.cursor,
};
// tg.style.opacity = '0.4';
// tg.style.border = '1px dotted red';
const r = tg.getBoundingClientRect();
if (e.clientX >= r.right - 10 || e.clientY >= r.bottom - 10) {
e.preventDefault();
this.m_drag.ty = 0;
}
}
}
onMMove(e: MouseEvent) {
const tg = e.target as HTMLElement;
const tgname = tg.nodeName.toUpperCase();
const press = e.buttons == 1;
if (this.m_drag && this.m_drag.o) {
if (this.m_drag.ty >= 0) {
if (!press) return this.onMUp(e);
}
if (this.m_drag.ty !== -1) {
window.getSelection()?.removeAllRanges();
}
if (this.m_drag.ty == 0) {
e.preventDefault();
this.m_drag.ty = 1;
} else if (this.m_drag.ty < 0 && press) {
if (e.clientX != this.m_drag.x || e.clientY != this.m_drag.y) {
// this.m_drag.o.style.opacity = this.m_drag.op;
// this.m_drag.o.style.border = this.m_drag.b;
this.m_drag.o.style.cursor = this.m_drag.c;
}
}
if (this.m_drag.ty == 1) {
let ww = this.m_drag.w + e.clientX - this.m_drag.x;
let hh = this.m_drag.h + e.clientY - this.m_drag.y;
if (ww < 5) ww = 5;
if (hh < 5) hh = 5;
this.m_drag.o.width = ww;
this.m_drag.o.height = hh;
}
}
if (tgname == 'IMG') {
const r = tg.getBoundingClientRect();
if (e.clientX >= r.right - 10 && e.clientY <= r.bottom - 20) {
tg.style.cursor = 'w-resize';
} else if (e.clientX <= r.right - 20 && e.clientY >= r.bottom - 10) {
tg.style.cursor = 'ns-resize';
} else if (e.clientX >= r.right - 20 && e.clientY >= r.bottom - 20) {
tg.style.cursor = 'nw-resize';
} else {
tg.style.cursor = '';
}
}
}
onMUp(e: MouseEvent) {
const tg = e.target as HTMLElement;
const tgname = tg.nodeName.toUpperCase();
if (this.m_drag && this.m_drag.o) {
// if (tgname != 'IMG') {
// this.m_drag.o.style.opacity = this.m_drag.op;
// this.m_drag.o.style.border = this.m_drag.b;
// }
this.m_drag.o.style.cursor = this.m_drag.c;
this.m_drag.ty = -1;
}
if (tgname == 'IMG') {
this.selObj(tg);
}
}
onDblClick() {}
setFocus() {
this.m_win.focus();
this.onFocus();
}
onBeforedeactivate() {
this.m_range = this.m_doc.selection.createRange();
this.m_focus = false;
}
execCommand(cmd: string, args?: any) {
this.setFocus();
this.m_doc.execCommand(cmd, false, args);
}
pickupColor(callback: (color: string) => void) {
// Save current selection before opening color picker
const sel = this.m_win.getSelection();
if (sel && sel.rangeCount > 0) {
this.m_savedRange = sel.getRangeAt(0).cloneRange();
}
const input = document.querySelector(`.${this.m_id}_color`) as HTMLInputElement;
this.m_pickingColor = true;
const apply = () => {
this.setFocus();
// Restore selection before applying color
if (this.m_savedRange) {
const restoreSel = this.m_win.getSelection();
if (restoreSel) {
restoreSel.removeAllRanges();
restoreSel.addRange(this.m_savedRange);
}
}
callback(input.value);
// Update our saved range to the newly formed DOM structure by execCommand
const afterSel = this.m_win.getSelection();
if (afterSel && afterSel.rangeCount > 0) {
this.m_savedRange = afterSel.getRangeAt(0).cloneRange();
}
};
input.oninput = apply;
input.onchange = () => {
apply();
setTimeout(() => {
this.m_pickingColor = false;
}, 200);
};
input.click();
}
pickupImage(callback: (url: string) => void) {
if (!HEditor._PickupImageHook) {
alert('HEditor: PickupImageHook not set');
return;
}
HEditor._PickupImageHook(callback);
}
init(html?: string) {
const dom = (
typeof this.selector === 'string' ? document.querySelector(this.selector) : this.selector
) as HTMLDivElement;
if (!dom) {
console.warn(`HEditor: ${this.selector} not found`);
return;
}
let _eid = dom.getAttribute('_eid');
if (!_eid) {
_eid = hEditorUniqueId();
dom.setAttribute('_eid', _eid);
}
this.m_id = _eid;
loadSvgIconStyles();
const s = `
`;
dom.innerHTML = s;
this.m_p = dom.querySelector(`#${_eid}_p`);
this.m_ifrm = dom.querySelector(`#${_eid}_ifrm`);
this.reset(html);
this.setPanel();
dom.style.resize = 'both';
dom.style.overflow = 'auto';
}
reset(html?: string) {
const fd = this.m_ifrm.contentDocument || this.m_ifrm.contentWindow.document;
fd.designMode = 'On';
fd.open();
fd.write(
`${
html || '
'
}`
);
fd.close();
this.m_doc = fd;
this.m_win = this.m_ifrm.contentWindow;
this.m_win.addEventListener('blur', this.onBlur.bind(this));
this.m_win.addEventListener('focus', this.onFocus.bind(this));
this.m_doc.addEventListener('mousedown', this.onMDown.bind(this));
this.m_doc.addEventListener('mousemove', this.onMMove.bind(this));
this.m_doc.addEventListener('mouseup', this.onMUp.bind(this));
this.m_win.addEventListener('dblclick', this.onDblClick.bind(this));
}
setPanel() {
let s = '';
if (this.m_buttons && this.m_buttons.length > 0) {
for (const btn of this.m_buttons) {
for (const k in HEditor._plugins) {
s += this.createPanel(k, HEditor._plugins[k].buttons, btn);
}
}
} else {
for (const k in HEditor._plugins) {
s += this.createPanel(k, HEditor._plugins[k].buttons);
}
}
this.m_p.innerHTML = s;
}
createPanel(pluginname: string, btns: { [key: string]: HEditorPluginButonProps }, onebutton?: string) {
let s = '';
for (const btnId in btns) {
if (onebutton && btnId != onebutton) continue;
const btn = btns[btnId];
if (btnId == '-') {
s += '
';
} else if (btn.list) {
s +=
`` +
`
';
} else {
const lab = HEditor.label(btn.label);
s +=
``;
}
}
return s;
}
getHtml() {
return this.m_doc.body.innerHTML;
}
setHtml(html: string) {
this.m_doc.body.innerHTML = html || '
';
}
pasteHTML(html: string) {
this.setFocus();
this.m_doc.execCommand('InsertHtml', false, html);
}
selObj(obj: HTMLElement) {
window.setTimeout(() => {
const selection = this.m_win.getSelection();
const range = this.m_doc.createRange();
range.selectNode(obj);
selection.removeAllRanges();
selection.addRange(range);
}, 100);
}
getCurrentLink() {
const sel = this.m_win.getSelection();
if (!sel || sel.rangeCount === 0) return null;
const node = sel.anchorNode;
if (!node) return null;
const link = (node.nodeType === Node.ELEMENT_NODE ? node : node.parentElement)?.closest('a');
return link;
}
showPanel(html: string) {
if (!html) {
this.m_p.parentNode!.querySelector(`#${this.m_id}_userpanel`)?.remove();
return;
}
const panel = document.createElement('div');
panel.innerHTML = html;
panel.id = `${this.m_id}_userpanel`;
panel.style.cssText =
'position:absolute;top:0;bottom:0;left:0;right:0;display:flex;flex-direction:column;z-index:10000;background-color:#d3d1d1fc;';
this.m_p.parentNode!.appendChild(panel);
return panel;
}
}
// const findOrCreateWrap = (win: Window, doc: Document): HTMLSpanElement | null => {
// const sel = win.getSelection();
// if (!sel || sel.rangeCount === 0) return null;
// const range = sel.getRangeAt(0);
// const startContainer = range.startContainer;
// const endContainer = range.endContainer;
// const span = startContainer.parentNode as HTMLSpanElement;
// if (startContainer === endContainer && ((startContainer as any).tagName === 'SPAN' || span.tagName === 'SPAN')) {
// if (span.tagName === 'SPAN') {
// return span;
// }
// return startContainer as HTMLSpanElement;
// }
// const span2 = document.createElement('span');
// const frag = range.extractContents();
// span2.appendChild(frag);
// range.insertNode(span2);
// const newRange = document.createRange();
// newRange.selectNodeContents(span2);
// sel.removeAllRanges();
// sel.addRange(newRange);
// return span2;
// };
// const editorApplyStyle = (win: Window, doc: Document, style: string, value: string) => {
// win.focus();
// const span = findOrCreateWrap(win, doc);
// if (!span) return;
// const domStyle = span.style as any;
// if (domStyle[style] === value) {
// value='';
// }
// domStyle[style] = value;
// };
// const editorApplySize = (win: Window, doc: Document, delta: number) => {
// win.focus();
// const span = findOrCreateWrap(win, doc);
// if (!span) return;
// const currentSize = parseFloat(win.getComputedStyle(span).fontSize) || 16;
// const domStyle = span.style as any;
// const newSize = Math.max(6, Math.min(100, currentSize + delta));
// domStyle.fontSize = newSize + 'px';
// };
const HEditorPluginFont: HEditorPluginProps = {
name: 'font',
buttons: {
fontsize: {
name: 'fontsize',
label: 'Size',
command: 'fontsize',
width: '50px',
list: {
'0': 'Size',
// '32px': '32px (H1)',
// '24px': '24px (H2)',
// '18.7px': '18.7px (H3)',
// '16px': '16px (H4, P)',
// '13.2px': '13.2px (H5)',
// '10.7px': '10.7px (H6)',
1: '1 (10px)',
2: '2 (13px)',
3: '3 (16px)',
4: '4 (18px)',
5: '5 (24px)',
6: '6 (32px)',
},
},
// fontplus: {
// name: 'fontplus',
// label: 'A+',
// },
// fontminus: {
// name: 'fontminus',
// label: 'A-',
// },
},
onSelectedChanged: (
editor: HEditor,
plugin: HEditorPluginProps,
btnid: string,
selText: string,
selValue: string
) => {
if (btnid == 'fontsize') {
if (selValue) {
editor.execCommand('fontsize', selValue);
// editorApplyStyle(editor.m_win, editor.m_doc, 'font-size', selValue);
}
}
},
// onButtonClick: (editor: HEditor, plugin: HEditorPluginProps, btnid: string) => {
// if (btnid == 'fontplus') {
// editor.setFocus();
// editorApplySize(editor.m_win, editor.m_doc, +1);
// } else if (btnid == 'fontminus') {
// editor.setFocus();
// editorApplySize(editor.m_win, editor.m_doc, -1);
// }
// },
};
HEditor.addPlugin(HEditorPluginFont.name, HEditorPluginFont);
const HEditorPluginBasic: HEditorPluginProps = {
name: 'basic',
buttons: {
bold: {
name: 'bold',
label: ``,
},
italic: {
name: 'italic',
label: ``,
},
underline: {
name: 'underline',
label: ``,
},
strikethrough: {
name: 'strikethrough',
label: ``,
},
left: {
name: 'left',
label: ``,
style: 'margin-left: 3px',
},
center: {
name: 'center',
label: ``,
},
right: {
name: 'right',
label: ``,
},
justify: {
name: 'justify',
label: ``,
},
ol: {
name: 'ol',
label: ``,
style: 'margin-left: 3px',
},
ul: {
name: 'ul',
label: ``,
},
indent: {
name: 'indent',
label: ``,
},
outdent: {
name: 'outdent',
label: ``,
},
removeformat: {
name: 'removeformat',
label: ``,
style: 'margin-left: 3px;margin-right: 3px;',
},
},
onButtonClick: (editor: HEditor, plugin: HEditorPluginProps, btnid: string) => {
if (btnid == 'bold') {
// editorApplyStyle(editor.m_win, editor.m_doc, 'font-weight', 'bold');
editor.execCommand('bold');
} else if (btnid == 'italic') {
// editorApplyStyle(editor.m_win, editor.m_doc, 'font-style', 'italic');
editor.execCommand('italic');
} else if (btnid == 'underline') {
// editorApplyStyle(editor.m_win, editor.m_doc, 'text-decoration', 'underline');
editor.execCommand('underline');
} else if (btnid == 'left') {
editor.execCommand('justifyleft');
} else if (btnid == 'center') {
editor.execCommand('justifycenter');
} else if (btnid == 'right') {
editor.execCommand('justifyright');
} else if (btnid == 'justify') {
editor.execCommand('justifyfull');
} else if (btnid == 'ol') {
editor.execCommand('insertorderedlist', 'OL');
} else if (btnid == 'ul') {
editor.execCommand('insertunorderedlist', 'UL');
} else if (btnid == 'strikethrough') {
// editorApplyStyle(editor.m_win, editor.m_doc, 'text-decoration', 'line-through');
editor.execCommand('strikeThrough');
} else if (btnid == 'removeformat') {
editor.execCommand('removeformat');
} else if (btnid == 'indent') {
editor.execCommand('indent');
} else if (btnid == 'outdent') {
editor.execCommand('outdent');
}
},
};
HEditor.addPlugin(HEditorPluginBasic.name, HEditorPluginBasic);
const HEditorPluginColor: HEditorPluginProps = {
name: 'color',
buttons: {
textColor: {
name: 'textColor',
label: ``,
},
backColor: {
name: 'backColor',
label: ``,
},
},
onButtonClick: (editor: HEditor, plugin: HEditorPluginProps, btnid: string) => {
if (btnid == 'textColor') {
editor.pickupColor((color) => {
editor.execCommand('forecolor', color);
});
} else if (btnid == 'backColor') {
editor.pickupColor((color) => {
editor.execCommand('hilitecolor', color);
});
// editorApplyStyle(editor.m_win, editor.m_doc, 'background-color', '#ffffff');
}
},
};
HEditor.addPlugin(HEditorPluginColor.name, HEditorPluginColor);
const HEditorPluginImage: HEditorPluginProps = {
name: 'image',
buttons: {
image: {
name: 'image',
label: ``,
style: 'margin-left: 3px;',
},
},
onButtonClick: (editor: HEditor, plugin: HEditorPluginProps, btnid: string) => {
if (btnid == 'image') {
editor.pickupImage(async (url) => {
editor.execCommand('insertImage', [url]);
// const img = new Image();
// img.onload = () => {
// const w = img.width;
// const h = img.height;
// editor.pasteHTML(
// ``
// );
// };
// img.src = url;
});
}
},
};
HEditor.addPlugin(HEditorPluginImage.name, HEditorPluginImage);
const HEditorPluginLink: HEditorPluginProps = {
name: 'link',
buttons: {
link: {
name: 'Link',
label: ``,
},
unlink: {
name: 'Unlink',
label: ``,
},
},
onButtonClick: (editor: HEditor, plugin: HEditorPluginProps, btnid: string) => {
if (btnid == 'link') {
const link = editor.getCurrentLink();
const linkValue = link?.getAttribute('href') || '';
const panel = editor.showPanel(
`
`
);
(plugin as any).panel = panel;
} else if (btnid == 'unlink') {
editor.execCommand('unlink');
} else if (btnid == 'ok') {
const value = (plugin as any).panel?.querySelector('input')?.value;
if (!value) {
editor.execCommand('unlink');
return;
}
editor.showPanel('');
const link = editor.getCurrentLink();
if (link) {
link.setAttribute('href', value);
return;
}
editor.execCommand('createLink', value);
} else if (btnid == 'cancel') {
editor.showPanel('');
}
},
};
HEditor.addPlugin(HEditorPluginLink.name, HEditorPluginLink);
const HEditorPluginHtml: HEditorPluginProps = {
name: 'html',
buttons: {
html: {
name: 'html',
label: ``,
style: 'margin-left: 3px;',
},
},
onButtonClick: (editor: HEditor, plugin: HEditorPluginProps, btnid: string) => {
if (btnid == 'html') {
const panel = editor.showPanel(
`
`
);
(plugin as any).panel = panel;
} else if (btnid == 'ok') {
editor.showPanel('');
editor.setHtml((plugin as any).panel?.querySelector('textarea')?.value || '');
} else if (btnid == 'cancel') {
editor.showPanel('');
}
},
};
HEditor.addPlugin(HEditorPluginHtml.name, HEditorPluginHtml);
const HEditorPluginUndo: HEditorPluginProps = {
name: 'undo',
buttons: {
undo: {
name: 'undo',
label: ``,
style: 'margin-left: 3px',
},
redo: {
name: 'redo',
label: ``,
},
},
onButtonClick: (editor: HEditor, plugin: HEditorPluginProps, btnid: string) => {
if (btnid == 'undo') {
editor.execCommand('undo');
} else if (btnid == 'redo') {
editor.execCommand('redo');
}
},
};
HEditor.addPlugin(HEditorPluginUndo.name, HEditorPluginUndo);