/// import { app } from '../../scripts/app.js' import * as shared from './comfy_shared.js' import { infoLogger, successLogger, errorLogger } from './comfy_shared.js' import { DEFAULT_CSS, DEFAULT_HTML, DEFAULT_MD, DEFAULT_MODE, DEFAULT_THEME, THEMES, CSS_RESET, DEMO_CONTENT, } from './note_plus.constants.js' import { LocalStorageManager } from './comfy_shared.js' const storage = new LocalStorageManager('mtb') /** * Uses `@mtb/markdown-parser` (a fork of marked) * It is statically stored to avoid having * more than 1 instance ever. * The size difference between both libraries... * ╭───┬────────────────────────────────┬──────────╮ * │ # │ name │ size │ * ├───┼────────────────────────────────┼──────────┤ * │ 0 │ web-dist/mtb_markdown_plus.mjs │ 1.2 MB │ <- with shiki * │ 1 │ web-dist/mtb_markdown.mjs │ 44.7 KB │ * ╰───┴────────────────────────────────┴──────────╯ */ let useShiki = storage.get('np-use-shiki', false) const makeResizable = (dialog) => { dialog.style.resize = 'both' dialog.style.transformOrigin = 'top left' dialog.style.overflow = 'auto' } const makeDraggable = (dialog, handle) => { let offsetX = 0 let offsetY = 0 let isDragging = false const onMouseMove = (e) => { if (isDragging) { dialog.style.left = `${e.clientX - offsetX}px` dialog.style.top = `${e.clientY - offsetY}px` } } const onMouseUp = () => { isDragging = false document.removeEventListener('mousemove', onMouseMove) document.removeEventListener('mouseup', onMouseUp) } handle.addEventListener('mousedown', (e) => { isDragging = true offsetX = e.clientX - dialog.offsetLeft offsetY = e.clientY - dialog.offsetTop document.addEventListener('mousemove', onMouseMove) document.addEventListener('mouseup', onMouseUp) }) } /** @extends {LGraphNode} */ class NotePlus extends LiteGraph.LGraphNode { // same values as the comfy note color = LGraphCanvas.node_colors.yellow.color bgcolor = LGraphCanvas.node_colors.yellow.bgcolor groupcolor = LGraphCanvas.node_colors.yellow.groupcolor /* NOTE: this is not serialized and only there to make multiple * note+ nodes in the same graph unique. */ uuid /** Stores the dialog observer*/ resizeObserver /** Live update the preview*/ live = true /** DOM height by adding child size together*/ calculated_height = 0 /** ????*/ _raw_html /** might not be needed anymore */ inner /** the dialog DOM widget*/ dialog /** widgets*/ /** used to store the raw value and display the parsed html at the same time*/ html_widget /** hidden widgets for serialization*/ css_widget edit_mode_widget theme_widget editorsContainer /** ACE editors instances*/ html_editor css_editor constructor() { super() this.uuid = shared.makeUUID() infoLogger('Constructing Note+ instance') shared.ensureMarkdownParser((_p) => { this.updateHTML() }) // - litegraph settings this.collapsable = true this.isVirtualNode = true this.shape = LiteGraph.BOX_SHAPE this.serialize_widgets = true // - default values, serialization is done through widgets this._raw_html = DEFAULT_MODE === 'html' ? DEFAULT_HTML : DEFAULT_MD // - state this.live = true this.calculated_height = 0 // - add widgets const cinner = document.createElement('div') this.inner = document.createElement('div') cinner.append(this.inner) this.inner.classList.add('note-plus-preview') cinner.style.margin = '0' cinner.style.padding = '0' this.html_widget = this.addDOMWidget('HTML', 'html', cinner, { setValue: (val) => { this._raw_html = val }, getValue: () => this._raw_html, getMinHeight: () => this.calculated_height, // (the edit button), onDraw: () => { // HACK: dirty hack for now until it's addressed upstream... this.html_widget.element.style.pointerEvents = 'none' // NOTE: not sure about this, it avoid the visual "bugs" but scrolling over the wrong area will affect zoom... // this.html_widget.element.style.overflow = 'scroll' }, hideOnZoom: false, }) this.setupSerializationWidgets() this.setupDialog() this.loadAceEditor() } /** * @param {CanvasRenderingContext2D} ctx canvas context * @param {any} _graphcanvas */ onDrawForeground(ctx, _graphcanvas) { if (this.flags.collapsed) return this.drawEditIcon(ctx) this.drawSideHandle(ctx) // DEBUG BACKGROUND // ctx.fillStyle = 'rgba(0, 255, 0, 0.3)' // const rect = this.rect // ctx.fillRect(rect.x, rect.y, rect.width, rect.height) } drawSideHandle(ctx) { const handleRect = this.sideHandleRect const chamfer = 20 ctx.beginPath() // top left ctx.moveTo(handleRect.x, handleRect.y + chamfer) // top right ctx.lineTo(handleRect.x + handleRect.width, handleRect.y) // bottom right ctx.lineTo( handleRect.x + handleRect.width, handleRect.y + handleRect.height, ) // bottom left ctx.lineTo(handleRect.x, handleRect.y + handleRect.height - chamfer) ctx.closePath() ctx.fillStyle = 'rgba(255, 255, 255, 0.05)' ctx.fill() } drawEditIcon(ctx) { const rect = this.iconRect // DEBUG ICON POSITION // ctx.fillStyle = 'rgba(0, 255, 0, 0.3)' // ctx.fillRect(rect.x, rect.y, rect.width, rect.height) const pencilPath = new Path2D( 'M21.28 6.4l-9.54 9.54c-.95.95-3.77 1.39-4.4.76-.63-.63-.2-3.45.75-4.4l9.55-9.55a2.58 2.58 0 1 1 3.64 3.65z', ) const folderPath = new Path2D( 'M11 4H6a4 4 0 0 0-4 4v10a4 4 0 0 0 4 4h11c2.21 0 3-1.8 3-4v-5', ) ctx.save() ctx.translate(rect.x, rect.y) ctx.scale(rect.width / 32, rect.height / 32) ctx.strokeStyle = 'rgba(255,255,255,0.4)' ctx.lineCap = 'round' ctx.lineJoin = 'round' ctx.lineWidth = 2.4 ctx.stroke(pencilPath) ctx.stroke(folderPath) ctx.restore() } /** * @param {number} x * @param {number} y * @param {{x:number,y:number,width:number,height:number}} rect * @returns {} */ inRect(x, y, rect) { rect = rect || this.iconRect return ( x >= rect.x && x <= rect.x + rect.width && y >= rect.y && y <= rect.y + rect.height ) } get rect() { return { x: 0, y: 0, width: this.size[0], height: this.size[1], } } get sideHandleRect() { const w = this.size[0] const h = this.size[1] const bw = 32 const bho = 64 return { x: w - bw, y: bho, width: bw, height: h - bho * 1.5, } } get iconRect() { const iconSize = 32 const iconMargin = 16 return { x: this.size[0] - iconSize - iconMargin, y: iconMargin * 1.5, width: iconSize, height: iconSize, } } onMouseDown(_e, localPos, _graphcanvas) { if (this.inRect(localPos[0], localPos[1])) { this.openEditorDialog() return true } return false } /* Hidden widgets to store note+ settings in the workflow (stripped in API)*/ setupSerializationWidgets() { infoLogger('Setup Serializing widgets') this.edit_mode_widget = this.addWidget( 'combo', 'Mode', DEFAULT_MODE, (me) => successLogger('Updating edit_mode', me), { values: ['html', 'markdown', 'raw'], }, ) this.css_widget = this.addWidget('text', 'CSS', DEFAULT_CSS, (val) => { successLogger(`Updating css ${val}`) }) this.theme_widget = this.addWidget( 'text', 'Theme', DEFAULT_THEME, (val) => { successLogger(`Setting theme ${val}`) }, ) shared.hideWidgetForGood(this, this.edit_mode_widget) shared.hideWidgetForGood(this, this.css_widget) shared.hideWidgetForGood(this, this.theme_widget) } setupDialog() { infoLogger('Setup dialog') this.dialog = new app.ui.dialog.constructor() this.dialog.element.classList.add('comfy-settings') Object.assign(this.dialog.element.style, { position: 'absolute', boxShadow: 'none', }) const subcontainer = this.dialog.textElement.parentElement if (subcontainer) { Object.assign(subcontainer.style, { width: '100%', }) } const closeButton = this.dialog.element.querySelector('button') closeButton.textContent = 'CANCEL' closeButton.id = 'cancel-editor-dialog' closeButton.title = "Cancel the changes since last opened (doesn't support live mode)" closeButton.disabled = this.live closeButton.style.background = this.live ? 'repeating-linear-gradient(45deg,#606dbc,#606dbc 10px,#465298 10px,#465298 20px)' : '' const saveButton = document.createElement('button') saveButton.textContent = 'SAVE' saveButton.onclick = () => { this.closeEditorDialog(true) } closeButton.onclick = () => { this.closeEditorDialog(false) } closeButton.before(saveButton) } teardownEditors() { this.css_editor.destroy() this.css_editor.container.remove() this.html_editor.destroy() this.html_editor.container.remove() } closeEditorDialog(accept) { infoLogger('Closing editor dialog', accept) if (accept && !this.live) { this.updateHTML(this.html_editor.getValue()) this.updateCSS(this.css_editor.getValue()) } if (this.resizeObserver) { this.resizeObserver.disconnect() this.resizeObserver = null } this.teardownEditors() this.dialog.close() } /** * @param {HTMLElement} elem */ hookResize(elem) { if (!this.resizeObserver) { const observer = () => { this.html_editor.resize() this.css_editor.resize() Object.assign(this.editorsContainer.style, { minHeight: `${(this.dialog.element.clientHeight / 100) * 50}px`, //'200px', }) } this.resizeObserver = new ResizeObserver(observer).observe(elem) } } openEditorDialog() { infoLogger(`Current edit mode ${this.edit_mode_widget.value}`) this.hookResize(this.dialog.element) const container = document.createElement('div') Object.assign(container.style, { display: 'flex', gap: '10px', flexDirection: 'column', }) this.editorsContainer = document.createElement('div') Object.assign(this.editorsContainer.style, { display: 'flex', gap: '10px', flexDirection: 'row', minHeight: this.dialog.element.offsetHeight, //'200px', width: '100%', }) container.append(this.editorsContainer) this.dialog.show('') this.dialog.textElement.append(container) const aceHTML = document.createElement('div') aceHTML.id = 'noteplus-html-editor' Object.assign(aceHTML.style, { width: '100%', height: '100%', minWidth: '300px', minHeight: 'inherit', }) this.editorsContainer.append(aceHTML) const aceCSS = document.createElement('div') aceCSS.id = 'noteplus-css-editor' Object.assign(aceCSS.style, { width: '100%', height: '100%', minHeight: 'inherit', }) this.editorsContainer.append(aceCSS) const live_edit = document.createElement('input') live_edit.type = 'checkbox' live_edit.checked = this.live live_edit.onchange = () => { this.live = live_edit.checked const cancel_button = this.dialog.element.querySelector( '#cancel-editor-dialog', ) if (cancel_button) { cancel_button.disabled = this.live cancel_button.style.background = this.live ? 'repeating-linear-gradient(45deg,#606dbc,#606dbc 10px,#465298 10px,#465298 20px)' : '' } } //- "Dynamic" elements const firstButton = this.dialog.element.querySelector('button') const syncUI = () => { let convert_to_html = this.dialog.element.querySelector('#convert-to-html') if (this.edit_mode_widget.value === 'markdown') { if (convert_to_html == null) { convert_to_html = document.createElement('button') convert_to_html.textContent = 'Convert to HTML (NO UNDO!)' convert_to_html.id = 'convert-to-html' convert_to_html.onclick = () => { const select_mode = this.dialog.element.querySelector('#edit_mode') const md = this.html_editor.getValue() this.edit_mode_widget.value = 'html' select_mode.value = 'html' MTB.mdParser.parse(md).then((content) => { this.html_widget.value = content this.html_editor.setValue(content) this.html_editor.session.setMode('ace/mode/html') this.updateHTML(this.html_widget.value) convert_to_html.remove() }) } firstButton.before(convert_to_html) } } else { if (convert_to_html != null) { convert_to_html.remove() convert_to_html = null } } select_mode.value = this.edit_mode_widget.value // the header for dragging the dialog const header = document.createElement('div') header.style.padding = '8px' header.style.cursor = 'move' header.style.backgroundColor = 'rgba(0,0,0,0.5)' header.style.userSelect = 'none' header.style.borderBottom = '1px solid #ddd' header.textContent = 'MTB Note+ Editor' container.prepend(header) makeDraggable(this.dialog.element, header) makeResizable(this.dialog.element) } //- combobox let theme_select = this.dialog.element.querySelector('#theme_select') if (!theme_select) { infoLogger('Creating combobox for select') theme_select = document.createElement('select') theme_select.name = 'theme' theme_select.id = 'theme_select' const addOption = (label) => { const option = document.createElement('option') option.value = label option.textContent = label theme_select.append(option) } for (const t of THEMES) { addOption(t) } theme_select.addEventListener('change', (event) => { const val = event.target.value this.setTheme(val) }) container.prepend(theme_select) } theme_select.value = this.theme_widget.value let select_mode = this.dialog.element.querySelector('#edit_mode') if (!select_mode) { infoLogger('Creating combobox for select') select_mode = document.createElement('select') select_mode.name = 'mode' select_mode.id = 'edit_mode' const addOption = (label) => { const option = document.createElement('option') option.value = label option.textContent = label select_mode.append(option) } addOption('markdown') addOption('html') select_mode.addEventListener('change', (event) => { const val = event.target.value this.edit_mode_widget.value = val if (this.html_editor) { this.html_editor.session.setMode(`ace/mode/${val}`) this.updateHTML(this.html_editor.getValue()) syncUI() } }) container.append(select_mode) } select_mode.value = this.edit_mode_widget.value syncUI() const live_edit_label = document.createElement('label') live_edit_label.textContent = 'Live Edit' // add a tooltip live_edit_label.title = 'When this is on, the editor will update the note+ whenever you change the text.' live_edit_label.append(live_edit) // select_mode.before(live_edit_label) container.append(live_edit_label) this.setupEditors() } loadAceEditor() { shared.loadScript('/mtb_async/ace/ace.js').catch((e) => { errorLogger(e) }) } onCreate() { errorLogger('NotePlus onCreate') } restoreNodeState(info) { this.html_widget.element.id = `note-plus-${this.uuid}` this.setMode(this.edit_mode_widget.value) this.setTheme(this.theme_widget.value) this.updateHTML(this.html_widget.value) this.updateCSS(this.css_widget.value) if (info?.size) { this.setSize(info.size) } } configure(info) { super.configure(info) infoLogger('Restoring serialized values', info) this.restoreNodeState(info) // - update view from serialzed data } onNodeCreated() { infoLogger('Node created', this.uuid) this.restoreNodeState({}) // this.html_widget.element.id = `note-plus-${this.uuid}` // this.setMode(this.edit_mode_widget.value) // this.setTheme(this.theme_widget.value) // this.updateHTML(this.html_widget.value) // widget is populated here since we called super // this.updateCSS(this.css_widget.value) } onRemoved() { infoLogger('Node removed', this.uuid) } getExtraMenuOptions() { const currentMode = this.edit_mode_widget.value const newMode = currentMode === 'html' ? 'markdown' : 'html' const debugItems = window.MTB?.DEBUG ? [ { content: 'Replace with demo content (debug)', callback: () => { this.html_widget.value = DEMO_CONTENT }, }, ] : [] return [ ...debugItems, { content: `Set to ${newMode}`, callback: () => { this.edit_mode_widget.value = newMode this.updateHTML(this.html_widget.value) }, }, ] } _setupEditor(editor) { this.setTheme(this.theme_widget.value) editor.setShowPrintMargin(false) editor.session.setUseWrapMode(true) editor.renderer.setShowGutter(false) editor.session.setTabSize(4) editor.session.setUseSoftTabs(true) editor.setFontSize(14) editor.setReadOnly(false) editor.setHighlightActiveLine(false) editor.setShowFoldWidgets(true) return editor } setTheme(theme) { this.theme_widget.value = theme if (this.html_editor) { this.html_editor.setTheme(`ace/theme/${theme}`) } if (this.css_editor) { this.css_editor.setTheme(`ace/theme/${theme}`) } } setMode(mode) { this.edit_mode_widget.value = mode if (this.html_editor) { this.html_editor.session.setMode(`ace/mode/${mode}`) } this.updateHTML(this.html_widget.value) } setupEditors() { infoLogger('NotePlus setupEditor') this.html_editor = ace.edit('noteplus-html-editor') this.css_editor = ace.edit('noteplus-css-editor') this.css_editor.session.setMode('ace/mode/css') this.setMode(DEFAULT_MODE) this._setupEditor(this.html_editor) this._setupEditor(this.css_editor) this.css_editor.session.on('change', (_delta) => { // delta.start, delta.end, delta.lines, delta.action if (this.live) { this.updateCSS(this.css_editor.getValue()) } }) this.html_editor.session.on('change', (_delta) => { // delta.start, delta.end, delta.lines, delta.action if (this.live) { this.updateHTML(this.html_editor.getValue()) } }) this.html_editor.setValue(this.html_widget.value) this.css_editor.setValue(this.css_widget.value) } scopeCss(css, scopeId) { return css .split('}') .map((rule) => { if (rule.trim() === '') { return '' } const scopedRule = rule .split('{') .map((segment, index) => { if (index === 0) { return `#${scopeId} ${segment.trim()}` } return `{${segment.trim()}` }) .join(' ') return `${scopedRule}}` }) .join('\n') } getCssDom() { const styleTagId = `note-plus-stylesheet-${this.uuid}` let styleTag = document.head.querySelector(`#${styleTagId}`) if (!styleTag) { styleTag = document.createElement('style') styleTag.type = 'text/css' styleTag.id = styleTagId document.head.appendChild(styleTag) infoLogger(`Creating note-plus-stylesheet-${this.uuid}`, styleTag) } return styleTag } calculateHeight() { this.calculated_height = shared.calculateTotalChildrenHeight( this.html_widget.element, ) this.setDirtyCanvas(true, true) } updateCSS(css) { infoLogger('NotePlus updateCSS') // this.html_widget.element.style = css const scopedCss = this.scopeCss( `${CSS_RESET}\n${css}`, `note-plus-${this.uuid}`, ) const cssDom = this.getCssDom() cssDom.innerHTML = scopedCss this.css_widget.value = css this.calculateHeight() infoLogger('NotePlus updateCSS', this.calculated_height) // this.setSize(this.computeSize()) } parserInitiated() { if (window.MTB?.mdParser) return true return false } /** to easilty swap purification methods*/ purify(content) { return DOMPurify.sanitize(content, { ADD_TAGS: ['iframe', 'detail', 'summary'], }) } updateHTML(val) { if (!this.parserInitiated()) { return } val = val || this.html_widget.value const isHTML = this.edit_mode_widget.value === 'html' const cleanHTML = this.purify(val) const value = isHTML ? cleanHTML : cleanHTML.replaceAll('>', '>').replaceAll('<', '<') // .replaceAll('&', '&') // .replaceAll('"', '"') // .replaceAll(''', "'") this.html_widget.value = value if (isHTML) { this.inner.innerHTML = value } else { MTB.mdParser.parse(value).then((e) => { this.inner.innerHTML = e }) } // this.html_widget.element.innerHTML = `
${value}` this.calculateHeight() // this.setSize(this.computeSize()) } } app.registerExtension({ name: 'mtb.noteplus', setup: () => { app.ui.settings.addSetting({ id: 'mtb.noteplus.use-shiki', category: ['mtb', 'Note+', 'use-shiki'], name: 'Use shiki to highlight code', tooltip: 'This will load a larger version of @mtb/markdown-parser that bundles shiki, it supports all shiki transformers (supported langs: html,css,python,markdown)', type: 'boolean', defaultValue: false, attrs: { style: { // fontFamily: 'monospace', }, }, async onChange(value) { storage.set('np-use-shiki', value) useShiki = value }, }) }, registerCustomNodes() { LiteGraph.registerNodeType('Note Plus (mtb)', NotePlus) NotePlus.category = 'mtb/utils' NotePlus.title = 'Note+ (mtb)' NotePlus.title_mode = LiteGraph.NO_TITLE }, })