import React from 'react'; import { createRoot } from 'react-dom/client'; // Automatically reload this view if certain other fragments change const changeFragments = [ '#App [data-type="text/javascript+babel"]' ]; // Start the app async function render() { if (!window.cachedAppRoot) { let element = document.createElement('transient'); element.id = 'app-root'; document.body.appendChild(element); window.cachedAppRoot = createRoot(element); } let content = await Fragment.one('#App .app').require(); window.cachedAppRoot.render(React.createElement(content.App)); }; let reloadTimer = null; const reload = () => { clearTimeout(reloadTimer); reloadTimer = setTimeout(async function reloadReact() { try { render(); } catch (ex) { console.log(ex); } }, 1000); }; changeFragments.forEach(frag => { let lookedUpFragments = Fragment.find(frag); lookedUpFragments.forEach((lookedUpFragment) => { lookedUpFragment.registerOnFragmentChangedHandler(() => { if (fragmentSelfReference.auto) { reload(); } }); }); }); reload(); import React, { createContext, useContext, useRef, useState, useCallback, useEffect } from 'react'; const EditorCtx = createContext(null); export function useEditor() { const ctx = useContext(EditorCtx); if (!ctx) throw new Error('useEditor must be used inside <EditorProvider>'); return ctx; } export function EditorProvider({ children }) { const editorRef = useRef(null); const [menu, setMenu] = useState(null); const [actions, setActions] = useState([]); // {id} only — just for ordering const [promptModal, setPromptModal] = useState(null); // Stable storage that does NOT trigger re-renders when it changes const reg = useRef({ counts: new Map(), // id -> number (StrictMode ref counting) labels: new Map(), // id -> ref<label> handlers: new Map(), // id -> ref<handler> shouldShows: new Map(), // id -> ref<shouldShow> }).current; const registerAction = useCallback((id, labelRef, handlerRef, shouldShowRef) => { setActions(prev => { const count = (reg.counts.get(id) || 0) + 1; reg.counts.set(id, count); // StrictMode safety: only insert into list on first real mount if (prev.some(a => a.id === id)) return prev; reg.labels.set(id, labelRef); reg.handlers.set(id, handlerRef); if (shouldShowRef) reg.shouldShows.set(id, shouldShowRef); return [...prev, { id }]; }); return () => { const count = (reg.counts.get(id) || 1) - 1; if (count <= 0) { reg.counts.delete(id); reg.labels.delete(id); reg.handlers.delete(id); reg.shouldShows.delete(id); setActions(prev => prev.filter(a => a.id !== id)); } else { reg.counts.set(id, count); } }; }, [reg]); const openMenu = useCallback((x, y, context) => setMenu({ x, y, context }), []); const closeMenu = useCallback(() => setMenu(null), []); const openPromptModal = useCallback((context, executePrompt) => setPromptModal({ isOpen: true, context, executePrompt }), []); const closePromptModal = useCallback(() => setPromptModal(null), []); const getLabel = useCallback((id) => reg.labels.get(id)?.current, [reg]); const getHandler = useCallback((id) => reg.handlers.get(id)?.current, [reg]); const getShouldShow = useCallback((id) => reg.shouldShows.get(id)?.current, [reg]); return ( <EditorCtx.Provider value={{ editorRef, registerAction, openMenu, closeMenu, menu, actions, getLabel, getHandler, getShouldShow, promptModal, openPromptModal, closePromptModal, }}> {children} </EditorCtx.Provider> ); } export function useContextMenuItem(id, label, handler, shouldShow) { const { registerAction } = useEditor(); // These refs are reassigned every render so they stay fresh, // but they NEVER trigger the useEffect below. const labelRef = useRef(label); const handlerRef = useRef(handler); const shouldShowRef = useRef(shouldShow); labelRef.current = label; handlerRef.current = handler; shouldShowRef.current = shouldShow; useEffect(() => { return registerAction(id, labelRef, handlerRef, shouldShowRef); // Only the stable ID (and the stable registerAction) matter now. // Inline arrow functions in your plugins are safe. }, [id, registerAction]); }// ------------------------------------------------------------ // 1. Caret & word selection // ------------------------------------------------------------ export function getCaretRangeFromPoint(x, y) { if (document.caretPositionFromPoint) { const pos = document.caretPositionFromPoint(x, y); if (!pos) return null; const r = document.createRange(); r.setStart(pos.offsetNode, pos.offset); r.setEnd(pos.offsetNode, pos.offset); return r; } if (document.caretRangeFromPoint) { return document.caretRangeFromPoint(x, y); } return null; } export function selectWordAt(range) { if (!range || !range.collapsed) return range; const node = range.startContainer; if (node.nodeType !== Node.TEXT_NODE) return range; const text = node.textContent; let start = range.startOffset; while (start > 0 && !/\s/.test(text[start - 1])) start--; let end = range.startOffset; while (end < text.length && !/\s/.test(text[end])) end++; const r = document.createRange(); r.setStart(node, start); r.setEnd(node, end); return r; } // ------------------------------------------------------------ // 2. DOM helpers // ------------------------------------------------------------ export function getFlatOffset(targetNode, targetOffset, ancestor) { let count = 0; const walker = document.createTreeWalker(ancestor, NodeFilter.SHOW_TEXT); let n; while ((n = walker.nextNode())) { if (n === targetNode) return count + targetOffset; count += n.textContent.length; } return count; } export function getParagraphElement(node, root) { let el = node.nodeType === Node.TEXT_NODE ? node.parentElement : node; while (el && el !== root) { const tag = el.tagName?.toLowerCase(); if (['p','li','div','h1','h2','h3','h4','h5','h6','blockquote','section','article'].includes(tag)) { return el; } el = el.parentElement; } return root; } export function findNodeAndOffsetAtChar(paragraphElement, charOffset) { let count = 0; const walker = document.createTreeWalker(paragraphElement, NodeFilter.SHOW_TEXT); let n; while ((n = walker.nextNode())) { const len = n.textContent.length; if (count + len >= charOffset) { return { node: n, offset: charOffset - count }; } count += len; } // If offset is at the very end if (n) { return { node: n, offset: n.textContent.length }; } return null; } // ------------------------------------------------------------ // 3. Mutation helpers // ------------------------------------------------------------ export function replaceRange(range, text) { // operate immediately while the range is still valid range.deleteContents(); const tn = document.createTextNode(text); range.insertNode(tn); range.setStartAfter(tn); range.collapse(true); const sel = window.getSelection(); sel.removeAllRanges(); sel.addRange(range); } export function wrapRange(range, tagName) { const el = document.createElement(tagName); el.appendChild(range.extractContents()); range.insertNode(el); const sel = window.getSelection(); const r = document.createRange(); r.selectNode(el); sel.removeAllRanges(); sel.addRange(r); } // ------------------------------------------------------------ // 4. Context helpers (simplified) // ------------------------------------------------------------ function findSentenceBounds(full, caret) { let s = caret; while (s > 0 && !'.!?'.includes(full[s - 1])) s--; let e = caret; while (e < full.length && !'.!?'.includes(full[e])) e++; if (e < full.length && '.!?'.includes(full[e])) e++; // include terminator return { start: s, end: e }; } export function getContextInfo(range, root) { const containerElement = getParagraphElement(range.commonAncestorContainer, root); if (!containerElement) return null; const full = containerElement.textContent; const selStart = getFlatOffset(range.startContainer, range.startOffset, containerElement); const selEnd = getFlatOffset(range.endContainer, range.endOffset, containerElement); const beforeBounds = findSentenceBounds(full, selStart); const afterBounds = findSentenceBounds(full, selEnd); const contextStart = beforeBounds.start; const contextEnd = afterBounds.end; return { text: full.slice(contextStart, contextEnd), selectionStart: selStart - contextStart, selectionEnd: selEnd - contextStart, containerElement, _docStart: contextStart, _docEnd: contextEnd, }; } export function replaceContextRange(ctxInfo, newText) { const { containerElement, _docStart, _docEnd } = ctxInfo; const startInfo = findNodeAndOffsetAtChar(containerElement, _docStart); const endInfo = findNodeAndOffsetAtChar(containerElement, _docEnd); if (!startInfo || !endInfo) return; const r = document.createRange(); r.setStart(startInfo.node, startInfo.offset); r.setEnd(endInfo.node, endInfo.offset); // Focus the editable surface first so execCommand targets the right document let focusTarget = containerElement; while (focusTarget && focusTarget.contentEditable !== 'true') { focusTarget = focusTarget.parentElement; } if (focusTarget) focusTarget.focus(); const sel = window.getSelection(); sel.removeAllRanges(); sel.addRange(r); // Use the browser's native undo stack for this replacement document.execCommand('insertText', false, newText); } import React, { useRef, useEffect, useState } from 'react'; import * as utils from '#App [name="text-utils"]'; /* ============================================================ Toolbar ============================================================ */ function exec(cmd, val = null) { document.execCommand(cmd, false, val); } function ToolbarButton({ cmd, label, variant, active, title }) { const className = [ 'toolbar__btn', active && 'toolbar__btn--active', variant && `toolbar__btn--${variant}`, ].filter(Boolean).join(' '); return ( <button type="button" className={className} onMouseDown={(e) => { e.preventDefault(); exec(cmd); }} title={title} > {label} </button> ); } function ToolbarSeparator() { return <span className="toolbar__separator" />; } function Toolbar({ surfaceRef }) { const [tick, setTick] = useState(0); useEffect(() => { const onSel = () => setTick(t => t + 1); document.addEventListener('selectionchange', onSel); return () => document.removeEventListener('selectionchange', onSel); }, []); useEffect(() => { const el = surfaceRef.current; if (!el) return; const onUp = () => setTick(t => t + 1); el.addEventListener('keyup', onUp); el.addEventListener('mouseup', onUp); return () => { el.removeEventListener('keyup', onUp); el.removeEventListener('mouseup', onUp); }; }, [surfaceRef]); const isActive = (cmd) => { try { return document.queryCommandState(cmd); } catch { return false; } }; const blockValue = () => { try { return (document.queryCommandValue('formatBlock') || '').toString().toLowerCase(); } catch { return ''; } }; const BlockButton = ({ tag, label, title }) => { const active = blockValue() === tag; return ( <button type="button" className={[ 'toolbar__btn', active && 'toolbar__btn--active', ].filter(Boolean).join(' ')} onMouseDown={(e) => { e.preventDefault(); exec('formatBlock', tag); }} title={title} > {label} </button> ); }; const Group = ({ children }) => ( <div className="toolbar__group"> {children} </div> ); return ( <div className="toolbar"> <Group> <ToolbarButton cmd="undo" title="Undo" label={<span unapproved="" className="material-icons-outlined">undo</span>} /> <ToolbarButton cmd="redo" title="Redo" label={<span unapproved="" className="material-icons-outlined">redo</span>} /> </Group> <ToolbarSeparator /> <Group> <ToolbarButton cmd="bold" title="Bold" label={<span unapproved="" className="material-icons-outlined">format_bold</span>} active={isActive('bold')} /> <ToolbarButton cmd="italic" title="Italic" label={<span unapproved="" className="material-icons-outlined">format_italic</span>} active={isActive('italic')} /> <ToolbarButton cmd="underline" title="Underline" label={<span unapproved="" className="material-icons-outlined">format_underlined</span>} active={isActive('underline')} /> <ToolbarButton cmd="strikeThrough" title="Strikethrough" label={<span unapproved="" className="material-icons-outlined">strikethrough_s</span>} active={isActive('strikeThrough')} /> </Group> <ToolbarSeparator /> <Group> <BlockButton tag="h1" title="Heading 1" label={<span unapproved="" className="material-icons-outlined">looks_one</span>} /> <BlockButton tag="h2" title="Heading 2" label={<span unapproved="" className="material-icons-outlined">looks_two</span>} /> <BlockButton tag="p" title="Paragraph" label={<span unapproved="" className="material-icons-outlined">text_fields</span>} /> </Group> <ToolbarSeparator /> <Group> <ToolbarButton cmd="justifyLeft" title="Align left" label={<span unapproved="" className="material-icons-outlined">format_align_left</span>} active={isActive('justifyLeft')} /> <ToolbarButton cmd="justifyCenter" title="Align center" label={<span unapproved="" className="material-icons-outlined">format_align_center</span>} active={isActive('justifyCenter')} /> <ToolbarButton cmd="justifyRight" title="Align right" label={<span unapproved="" className="material-icons-outlined">format_align_right</span>} active={isActive('justifyRight')} /> </Group> <ToolbarSeparator /> <Group> <ToolbarButton cmd="insertUnorderedList" title="Bulleted list" label={<span unapproved="" className="material-icons-outlined">format_list_bulleted</span>} active={isActive('insertUnorderedList')} /> <ToolbarButton cmd="insertOrderedList" title="Numbered list" label={<span unapproved="" className="material-icons-outlined">format_list_numbered</span>} active={isActive('insertOrderedList')} /> </Group> </div> ); } /* ============================================================ TextEditor ============================================================ */ export function TextEditor({ useEditor, defaultValue }) { const { editorRef, openMenu, closeMenu } = useEditor(); const surfaceRef = useRef(null); const initialized = useRef(false); const saveTimer = useRef(null); useEffect(() => { if (surfaceRef.current && !initialized.current) { const saved = localStorage.getItem('wpm-document'); surfaceRef.current.innerHTML = saved ?? defaultValue ?? `<h1>Untitled Document</h1> <p>Start writing here. Use the toolbar above to format your text.</p>`; initialized.current = true; surfaceRef.current.focus(); } }, [defaultValue]); /* ---- Debounced save to localStorage ---- */ useEffect(() => { const el = surfaceRef.current; if (!el) return; const onInput = () => { clearTimeout(saveTimer.current); saveTimer.current = setTimeout(() => { localStorage.setItem('wpm-document', el.innerHTML); }, 3000); }; el.addEventListener('input', onInput); return () => { el.removeEventListener('input', onInput); clearTimeout(saveTimer.current); }; }, []); const handleContextMenu = (e) => { e.preventDefault(); const sel = window.getSelection(); let range = sel.rangeCount ? sel.getRangeAt(0).cloneRange() : null; if (!range || range.collapsed) { const caret = utils.getCaretRangeFromPoint(e.clientX, e.clientY); if (caret) { range = utils.selectWordAt(caret); sel.removeAllRanges(); sel.addRange(range); } } if (!range) return; const root = surfaceRef.current; const ctxInfo = utils.getContextInfo(range, root); if (!ctxInfo) return; const context = { text: ctxInfo.text, selectionStart: ctxInfo.selectionStart, selectionEnd: ctxInfo.selectionEnd, containerElement: ctxInfo.containerElement, replace: (newText) => utils.replaceContextRange(ctxInfo, newText), }; openMenu(e.clientX, e.clientY, context); }; return ( <div className="editor-page"> <Toolbar surfaceRef={surfaceRef} /> <div className="editor-container"> <div ref={(node) => { surfaceRef.current = node; editorRef.current = node; }} className="editor-surface" contentEditable suppressContentEditableWarning onContextMenu={handleContextMenu} onClick={closeMenu} /> </div> <ContextMenu useEditor={useEditor} /> </div> ); } /* ============================================================ ContextMenu ============================================================ */ function ContextMenu({ useEditor }) { const { menu, closeMenu, actions, getLabel, getHandler, getShouldShow } = useEditor(); useEffect(() => { if (!menu) return; const onClick = () => closeMenu(); const onScroll = () => closeMenu(); const onResize = () => closeMenu(); const onKeyDown = (e) => { if (e.key === 'Escape') closeMenu(); }; window.addEventListener('click', onClick); window.addEventListener('scroll', onScroll, true); window.addEventListener('resize', onResize); window.addEventListener('keydown', onKeyDown); return () => { window.removeEventListener('click', onClick); window.removeEventListener('scroll', onScroll, true); window.removeEventListener('resize', onResize); window.removeEventListener('keydown', onKeyDown); }; }, [menu, closeMenu]); if (!menu) return null; const { x, y, context } = menu; const visible = []; for (const a of actions) { const shouldShow = getShouldShow(a.id); if (!shouldShow || shouldShow(context)) { visible.push(a); } } if (visible.length === 0) return null; return ( <div className="context-menu" style={{ left: Math.min(x + 8, window.innerWidth - 220), top: Math.min(y + 8, window.innerHeight - visible.length * 36), }} onClick={e => e.stopPropagation()} > {visible.map(a => ( <div key={a.id} className="context-menu__item" onClick={() => { const handler = getHandler(a.id); handler?.(context); closeMenu(); }} > {getLabel(a.id)} </div> ))} </div> ); } import React, { useCallback } from 'react'; export function SentenceLoggerPlugin({ useContextMenuItem }) { const handler = useCallback((ctx) => { console.log('=== TEXT CONTEXT ==='); console.log('Text :', ctx.text); console.log('Selection :', ctx.text.slice(ctx.selectionStart, ctx.selectionEnd)); console.log('Start :', ctx.selectionStart); console.log('End :', ctx.selectionEnd); alert( `Text: "${ctx.text}"\n\nSelection: "${ctx.text.slice(ctx.selectionStart, ctx.selectionEnd)}"` ); }, []); useContextMenuItem('log-sentence', 'Inspect context…', handler); return null; } export function HighlightParagraphPlugin({ useContextMenuItem }) { const handler = useCallback((ctx) => { if (!ctx.containerElement) return; const el = ctx.containerElement; el.style.backgroundColor = el.style.backgroundColor === 'yellow' ? '' : 'yellow'; }, []); useContextMenuItem('hl-para', 'Toggle paragraph highlight', handler); return null; } import React from 'react'; import { createRoot } from 'react-dom/client'; import { EditorProvider, useEditor, useContextMenuItem, } from '#App [name="editor-context"]'; import { TextEditor } from '#App [name="editor"]'; import { PromptModal } from '#App [name="prompt-modal"]'; import { DLLMReimaginePlugin } from '#App [name="plugin-dllm-reimagine"]'; import { SentenceLoggerPlugin, HighlightParagraphPlugin, } from '#App [name="plugins"]'; window.activeModel = { name: "haic/llada2.1-mini-pruned128", params: { temp: { type: 'range', min: 0, max: 2, step: 0.1, default: 0.7, hint: 'Sampling temperature', }, steps: { type: 'range', min: 1, max: 128, step: 1, default: 20, hint: 'Number of diffusion steps', }, remasking: { type: 'enum', values: [ { value: 'low_confidence', title: 'Low Confidence', hint: 'Remask low confidence tokens' }, { value: 'random', title: 'Random', hint: 'Random remasking' }, ], default: 'low_confidence', hint: 'Remasking strategy', }, blocksize: { type: 'range', min: 1, max: 128, step: 1, default: 8, hint: 'Block size for parallel decoding', }, seed: { type: 'range', min: 0, max: 999999, step: 1, default: 42, hint: 'Random seed', }, }, }; export function App() { return ( <EditorProvider> <TextEditor useEditor={useEditor} /> <DLLMReimaginePlugin useEditor={useEditor} useContextMenuItem={useContextMenuItem} /> <SentenceLoggerPlugin useContextMenuItem={useContextMenuItem} /> <HighlightParagraphPlugin useContextMenuItem={useContextMenuItem} /> <PromptModal useEditor={useEditor} /> <div className="prototype-overlay"> Prototype! <div className="prototype-overlay__sub">Use at own risk</div> </div> </EditorProvider> ); } import React, { useState, useEffect } from 'react'; /* ------------------------------------------------------------ Diff helpers ------------------------------------------------------------ */ function diffChars(oldStr, newStr) { const a = Array.from(oldStr); const b = Array.from(newStr); const m = a.length, n = b.length; if (m === 0 && n === 0) return []; if (m === 0) return [{ start: 0, end: n }]; if (n === 0) return []; const dp = Array.from({ length: m + 1 }, () => Array(n + 1).fill(0)); for (let i = m - 1; i >= 0; i--) { for (let j = n - 1; j >= 0; j--) { if (a[i] === b[j]) dp[i][j] = 1 + dp[i + 1][j + 1]; else dp[i][j] = Math.max(dp[i + 1][j], dp[i][j + 1]); } } const ops = []; let i = 0, j = 0; while (i < m || j < n) { if (i < m && j < n && a[i] === b[j]) { ops.push({ type: 'same' }); i++; j++; } else if (j >= n || (i < m && dp[i + 1][j] >= dp[i][j + 1])) { ops.push({ type: 'del' }); i++; } else { ops.push({ type: 'add' }); j++; } } let bIdx = 0; const ranges = []; let start = -1; for (const op of ops) { if (op.type === 'same') { if (start !== -1) { ranges.push({ start, end: bIdx }); start = -1; } bIdx++; } else if (op.type === 'add') { if (start === -1) start = bIdx; bIdx++; } } if (start !== -1) ranges.push({ start, end: bIdx }); return ranges; } function escapeHtml(str) { return str.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>'); } /* ------------------------------------------------------------ Settings builder ------------------------------------------------------------ */ function buildInitialSettings(params) { const s = {}; for (const [k, v] of Object.entries(params)) { s[k] = v.default !== undefined ? v.default : (v.type === 'range' ? (v.min || 0) : (v.type === 'enum' ? (v.values?.[0]?.value || '') : (v.type === 'checkbox' ? !!v.default : ''))); } return s; } /* ------------------------------------------------------------ ConfigParam ------------------------------------------------------------ */ function ConfigParam({ name, config, value, onChange }) { const { type, hint, name: configName } = config || {}; const displayName = configName || name; if (type === 'range') { return ( <div className="config-param"> <label className="config-param__label"> {displayName} <span className="config-param__value">{value}</span> </label> <input type="range" min={config.min} max={config.max} step={config.step} value={value} title={hint || ''} onChange={(e) => onChange(parseFloat(e.target.value))} /> </div> ); } if (type === 'enum') { return ( <div className="config-param"> <label className="config-param__label">{displayName}</label> <select value={value} title={hint || ''} onChange={(e) => onChange(e.target.value)}> {config.values.map((v) => ( <option key={v.value} value={v.value} title={v.hint}> {v.title || v.value} </option> ))} </select> </div> ); } if (type === 'checkbox') { return ( <div className="config-param"> <label className="config-param__label" style={{ display: 'flex', alignItems: 'center', gap: 8, cursor: 'pointer' }} title={hint || ''}> <input type="checkbox" checked={!!value} onChange={(e) => onChange(e.target.checked)} /> <span>{displayName}</span> </label> </div> ); } if (type === 'text') { return ( <div className="config-param"> <label className="config-param__label">{displayName}</label> <textarea value={value} title={hint || ''} onChange={(e) => onChange(e.target.value)} rows={4} /> </div> ); } return null; } /* ------------------------------------------------------------ PromptModal ------------------------------------------------------------ */ export function PromptModal({ useEditor }) { const { promptModal, closePromptModal } = useEditor(); const isOpen = promptModal?.isOpen || false; const modalContext = promptModal?.context || {}; const pluginParams = modalContext.pluginParams || {}; const executePrompt = promptModal?.executePrompt; const activeModel = window.activeModel || { params: {} }; const modelParams = activeModel.params || {}; /* -- All hooks before any conditional return -- */ const [modelSettings, setModelSettings] = useState(() => buildInitialSettings(modelParams)); const [pluginSettings, setPluginSettings] = useState(() => buildInitialSettings(pluginParams)); const [outputText, setOutputText] = useState(''); const [isLoading, setIsLoading] = useState(false); const [error, setError] = useState(null); // Ensure settings always contain valid defaults so inputs are never uncontrolled const effectiveModelSettings = React.useMemo( () => ({ ...buildInitialSettings(modelParams), ...modelSettings }), [modelParams, modelSettings] ); const effectivePluginSettings = React.useMemo( () => ({ ...buildInitialSettings(pluginParams), ...pluginSettings }), [pluginParams, pluginSettings] ); // Reset when modal opens useEffect(() => { if (!isOpen) return; setModelSettings(buildInitialSettings(modelParams)); setPluginSettings(buildInitialSettings(pluginParams)); setOutputText(''); setError(null); }, [isOpen, promptModal]); // Execute prompt on settings changes useEffect(() => { if (!isOpen || !executePrompt) return; let cancelled = false; const timer = setTimeout(() => { setIsLoading(true); setError(null); const settings = { model: modelSettings, plugin: pluginSettings, }; Promise.resolve(executePrompt(settings, modalContext)) .then((result) => { if (cancelled) return; if (!result) return; setOutputText(result ?? ''); }) .catch((err) => { if (cancelled) return; setError(String(err)); }) .finally(() => { if (!cancelled) setIsLoading(false); }); }, 100); return () => { cancelled = true; clearTimeout(timer); }; }, [modelSettings, pluginSettings, isOpen, executePrompt]); // Close modal on Escape key useEffect(() => { if (!isOpen) return; const handleKeyDown = (e) => { if (e.key === 'Escape') closePromptModal(); }; document.addEventListener('keydown', handleKeyDown); return () => document.removeEventListener('keydown', handleKeyDown); }, [isOpen, closePromptModal]); /* -- Early return AFTER all hooks -- */ if (!isOpen) return null; /* -- Render helpers -- */ const { text, selectionStart, selectionEnd, replace } = modalContext; const buildInputHtml = () => { if (!text) return ''; const before = escapeHtml(text.slice(0, selectionStart)); const selTxt = escapeHtml(text.slice(selectionStart, selectionEnd)); const after = escapeHtml(text.slice(selectionEnd)); return `<div class="prompt-modal__input-body">${before}<span class="highlight-selection">${selTxt}</span>${after}</div>`; }; const buildOutputHtml = () => { if (!outputText) return ''; const ranges = diffChars(text, outputText); let result = ''; let rangeIdx = 0; let inDiff = false; Array.from(outputText).forEach((ch, i) => { const isChanged = rangeIdx < ranges.length && i >= ranges[rangeIdx].start && i < ranges[rangeIdx].end; if (isChanged && !inDiff) { result += '<span class="highlight-diff">'; inDiff = true; } else if (!isChanged && inDiff) { result += '</span>'; inDiff = false; } result += escapeHtml(ch); }); if (inDiff) result += '</span>'; return `<div class="prompt-modal__output-body">${result}</div>`; }; const inputHtml = buildInputHtml(); const outputDisplayHtml = buildOutputHtml(); const handleAccept = () => { if (isLoading || !outputText) return; replace(outputText); closePromptModal(); }; const handleOverlayClick = (e) => { if (e.target === e.currentTarget) closePromptModal(); }; return ( <div className="prompt-modal-overlay" onClick={handleOverlayClick}> <div className="prompt-modal"> <button className="prompt-modal__close" onClick={closePromptModal} aria-label="Close">×</button> <div className="prompt-modal__body"> <div className="prompt-modal__config"> <h3>Configure Effect</h3> {Object.entries(pluginParams).map(([k, v]) => ( <ConfigParam key={k} name={k} config={v} value={effectivePluginSettings[k]} onChange={(val) => setPluginSettings(s => ({ ...s, [k]: val }))} /> ))} <h3>Model Parameters</h3> {Object.entries(modelParams).map(([k, v]) => ( <ConfigParam key={k} name={k} config={v} value={effectiveModelSettings[k]} onChange={(val) => setModelSettings(s => ({ ...s, [k]: val }))} /> ))} </div> <div className="prompt-modal__preview"> <div className="prompt-modal__panes"> <div className="prompt-modal__section"> <div className="prompt-modal__section-title">Input</div> <div className="prompt-modal__doc-piece" dangerouslySetInnerHTML={{ __html: inputHtml }} /> </div> <div className="prompt-modal__section"> <div className="prompt-modal__section-title">Output {isLoading && <span className="prompt-modal__loading">⏳</span>}</div> <div className="prompt-modal__doc-piece prompt-modal__doc-piece--output"> {error ? ( <div className="prompt-modal__error">{error}</div> ) : ( <div dangerouslySetInnerHTML={{ __html: outputDisplayHtml || '<span style="color:#999">Loading model, hang in there for 15 seconds…</span>' }} /> )} </div> </div> </div> <div className="prompt-modal__actions"> <button className="prompt-modal__btn prompt-modal__btn--secondary" onClick={closePromptModal}>Cancel</button> <button className="prompt-modal__btn prompt-modal__btn--primary" onClick={handleAccept} disabled={isLoading || !outputText} > Accept </button> </div> </div> </div> </div> </div> ); } import React, { useCallback } from 'react'; /* ============================================================ DLLMReimaginePlugin – LLaDA-powered text reimagining ============================================================ */ const ENDPOINT = 'https://litellm.stream.cavi.au.dk/v1/chat/completions'; function ensureApiKey() { if (window.litellm_key) return window.litellm_key; const key = window.prompt('Enter your LiteLLM API key:'); if (key) { window.litellm_key = key; return key; } throw new Error('No API key provided'); } async function* streamCompletion(apiKey, body) { const resp = await fetch(ENDPOINT, { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${apiKey}`, }, body: JSON.stringify(body), }); if (!resp.ok) { const text = await resp.text(); throw new Error(`HTTP ${resp.status}: ${text}`); } const reader = resp.body.getReader(); const decoder = new TextDecoder(); let buffer = ''; while (true) { const { done, value } = await reader.read(); if (done) break; buffer += decoder.decode(value, { stream: true }); const lines = buffer.split('\n'); buffer = lines.pop(); for (const line of lines) { const trimmed = line.trim(); if (!trimmed || !trimmed.startsWith('data: ')) continue; const jsonStr = trimmed.slice(6); if (jsonStr === '[DONE]') continue; try { const chunk = JSON.parse(jsonStr); const content = chunk.choices?.[0]?.delta?.content; if (content != null) yield content; } catch { // ignore parse errors for malformed chunks } } } // trailing buffer if (buffer.trim()) { const trimmed = buffer.trim(); if (trimmed.startsWith('data: ')) { const jsonStr = trimmed.slice(6); if (jsonStr !== '[DONE]') { try { const chunk = JSON.parse(jsonStr); const content = chunk.choices?.[0]?.delta?.content; if (content != null) yield content; } catch { // ignore } } } } } export function DLLMReimaginePlugin({ useEditor, useContextMenuItem }) { const { openPromptModal } = useEditor(); const executePrompt = useCallback(async (settings, context) => { const apiKey = ensureApiKey(); const model = window.activeModel?.name || 'haic/llada2.1-mini-pruned128'; const { text, selectionStart, selectionEnd } = context; const plugin = settings.plugin || {}; const userPrompt = plugin.prompt || ''; const selectedPart = text.slice(selectionStart, selectionEnd); const wordCount = selectedPart.split(' ').filter((w) => w.length > 0).length; const dots = '…'.repeat(Math.max(1, wordCount)); const systemPrompt = 'No thinking, imagine a reply that consist with the user instructions'; const before = text.slice(0, selectionStart); const after = text.slice(selectionEnd); const assistantContent = before + dots + after; const messages = [ { role: 'system', content: systemPrompt }, { role: 'user', content: userPrompt }, { role: 'assistant', content: assistantContent }, ]; const body = { model, messages, stream: true, extra_body: { temperature: settings.model?.temp ?? 0.7, steps: settings.model?.steps ?? 20, remasking: settings.model?.remasking ?? 'low_confidence', block_size: settings.model?.blocksize ?? 8, seed: settings.model?.seed ?? 42, includeSteps: true, } }; let content = ''; for await (const chunk of streamCompletion(apiKey, body)) { content += chunk; } return content; }, []); const handler = useCallback( (ctx) => { const pluginParams = { prompt: { name: 'Prompt', type: 'text', default: '', hint: 'Optional instructions for the reimagining', } }; openPromptModal({ ...ctx, pluginParams }, executePrompt); }, [openPromptModal, executePrompt] ); useContextMenuItem( 'dllm-reimagine', 'Reimagine…', handler, (ctx) => ctx.selectionEnd > ctx.selectionStart ); return null; } body { background: grey; padding: 0; margin: 0; } /* iOS-style overlaid scrollbars */ * { scrollbar-width: thin; scrollbar-color: rgba(80,80,80,0.35) transparent; } ::-webkit-scrollbar { width: 6px; height: 6px; } ::-webkit-scrollbar-track { background: transparent; } ::-webkit-scrollbar-thumb { background: rgba(80, 80, 80, 0.25); border-radius: 3px; transition: background 0.4s ease; } *:hover::-webkit-scrollbar-thumb { background: rgba(80, 80, 80, 0.55); } .editor-page { background: #e4e4e4; min-height: 100vh; } .toolbar { position: sticky; top: 0; z-index: 1; background: #f3f3f3; border-bottom: 1px solid #ccc; padding: 8px 16px; display: flex; align-items: center; justify-content: center; flex-wrap: wrap; gap: 4px; font-family: system-ui, sans-serif; &__group { display: inline-flex; align-items: center; margin-right: 4px; } &__separator { display: inline-block; width: 1px; height: 24px; background: #ccc; margin: 0 6px; vertical-align: middle; } &__btn { min-width: 32px; height: 32px; border: 1px solid #ccc; border-radius: 4px; background: #fff; cursor: pointer; font-size: 14px; display: inline-flex; align-items: center; justify-content: center; margin-right: 4px; color: #333; &--active { background: #d0d0d0; } .material-icons-outlined { font-size: 20px; } &--small { font-size: 13px; } &--bold { font-weight: 700; } &--italic { font-style: italic; } &--underline { text-decoration: underline; } } } .editor-container { padding: 2rem 1rem; } .editor-surface { max-width: 210mm; min-height: 297mm; margin: 0 auto; padding: 25mm 20mm; background: #fff; box-shadow: 0 0 12px rgba(0, 0, 0, 0.15); border-radius: 2px; line-height: 1.6; font-family: Georgia, "Times New Roman", serif; font-size: 16px; color: #222; outline: none; } .context-menu { position: fixed; background: #fff; border: 1px solid #ddd; border-radius: 8px; box-shadow: 0 8px 24px rgba(0, 0, 0, 0.15); padding: 4px 0; min-width: 200px; z-index: 10000; &__item { padding: 10px 16px; cursor: pointer; font-size: 14px; color: #222; border-bottom: 1px solid #f0f0f0; &:last-child { border-bottom: none; } &:hover { background: #f5f5f5; } } } .prototype-overlay { position: fixed; bottom: 32px; right: -55px; background: rgba(220, 53, 34, 0.92); color: #fff; font-family: system-ui, sans-serif; font-size: 13px; font-weight: 700; text-align: center; letter-spacing: 1.5px; text-transform: uppercase; padding: 10px 70px; transform: rotate(-45deg); box-shadow: 0 3px 10px rgba(0, 0, 0, 0.3); line-height: 1.3; z-index: 10001; pointer-events: none; white-space: nowrap; &__sub { font-size: 10px; font-weight: 600; letter-spacing: 0.5px; opacity: 0.95; text-transform: none; margin-top: 2px; } } /* ============================================================ PromptModal styles ============================================================ */ .prompt-modal-overlay { position: fixed; inset: 0; background: rgba(0, 0, 0, 0.45); z-index: 20000; display: flex; align-items: center; justify-content: center; padding: 24px; overscroll-behavior: contain; touch-action: none; } .prompt-modal { background: #fff; border-radius: 8px; box-shadow: 0 16px 48px rgba(0, 0, 0, 0.25); width: 100%; max-width: Min(100em, 90vw); /* intentional uppercase to pass thru min to css from scss */ max-height: 85vh; display: flex; flex-direction: column; overflow: hidden; position: relative; &__close { position: absolute; top: 8px; right: 12px; background: transparent; border: none; font-size: 22px; color: #888; cursor: pointer; z-index: 2; line-height: 1; &:hover { color: #333; } } &__body { display: flex; flex-direction: row; flex: 1; overflow: hidden; } &__config { width: 280px; flex-shrink: 0; background: #f1f3f4; color: black; padding: 20px; overflow-y: auto; h3 { font-family: system-ui, sans-serif; font-size: 13px; text-transform: uppercase; letter-spacing: 1px; color: #555; margin: 20px 0 10px; border-bottom: 1px solid #bbb; padding-bottom: 4px; &:first-child { margin-top: 0; } } } &__preview { flex: 1; display: flex; flex-direction: column; gap: 12px; padding: 20px; overflow: hidden; background: #f8f9fa; } &__panes { flex: 1; display: flex; flex-direction: row; gap: 12px; overflow: hidden; } &__section { flex: 1; min-width: 0; display: flex; flex-direction: column; overflow: hidden; } &__section-title { font-family: system-ui, sans-serif; font-size: 12px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.8px; color: #555; margin-bottom: 8px; flex-shrink: 0; position: relative; padding-right: 20px; } &__doc-piece { font-family: Georgia, "Times New Roman", serif; font-size: 15px; line-height: 1.7; color: #222; background: #fff; padding: 10px; border-radius: 4px; border: 1px dashed #ddd; overflow-wrap: break-word; flex: 1; overflow-y: auto; min-height: 0; position: relative; &::before, &::after { content: ''; position: absolute; left: 0; right: 0; height: 6px; background-repeat: repeat-x; pointer-events: none; } &::before { top: -3px; background-image: radial-gradient(circle at 50% 0, transparent 3px, #fff 4px); background-size: 8px 8px; background-position: top; } &::after { bottom: -3px; background-image: radial-gradient(circle at 50% 100%, transparent 3px, #fff 4px); background-size: 8px 8px; background-position: bottom; } } &__doc-piece--output { background: #fcfcfc; border-style: solid; &::before { background-image: radial-gradient(circle at 50% 0, transparent 3px, #fcfcfc 4px); } &::after { background-image: radial-gradient(circle at 50% 100%, transparent 3px, #fcfcfc 4px); } } &__input-body, &__output-body { white-space: pre-wrap; } /* torn-edge pseudo-elements applied to &__doc-piece instead */ &__actions { display: flex; justify-content: flex-end; gap: 8px; margin-top: 4px; } &__btn { padding: 8px 18px; border-radius: 6px; font-size: 14px; cursor: pointer; font-family: system-ui, sans-serif; border: 1px solid transparent; &--primary { background: #1a73e8; color: #fff; border-color: #1a73e8; &:hover:not(:disabled) { background: #1557b0; } &:disabled { opacity: 0.5; cursor: not-allowed; } } &--secondary { background: #fff; color: #333; border-color: #ccc; &:hover { background: #f5f5f5; } } } &__loading { position: absolute; right: 0; top: 50%; transform: translateY(-50%); font-weight: 400; color: #888; } &__error { color: #c5221f; font-size: 13px; background: #fce8e8; padding: 8px 10px; border-radius: 4px; border: 1px solid #f5c2c7; } } /* ConfigParam inside light panel */ .config-param { display: grid; grid-template-columns: 1fr 1.5fr; gap: 8px; align-items: center; border-radius: 4px; margin: 0.25em; &__label { font-size: 13px; font-weight: 600; color: #333; background: #e8eaed; padding: 4px 6px; border-radius: 3px; height: 100%; display: flex; align-items: center; } &__value { font-weight: 700; color: #1a73e8; margin-left: 4px; } input[type="range"] { width: 100%; cursor: pointer; } select { width: 100%; padding: 4px 6px; font-size: 13px; border-radius: 4px; border: 1px solid #999; background: #fff; color: #333; } textarea { width: 100%; min-height: 60px; padding: 6px 8px; font-size: 13px; border-radius: 4px; border: 1px solid #999; background: #fff; color: #333; resize: vertical; font-family: system-ui, sans-serif; box-sizing: border-box; } } /* Text-highlight helpers */ .highlight-selection { background: #d4edda; padding: 0 2px; border-radius: 2px; } .highlight-sentence { background: #cce5ff; padding: 0 4px; border-radius: 3px; } .highlight-paragraph { background: #fffbea; padding: 4px 6px; border-radius: 4px; display: inline; } .highlight-diff { background: #ffeb3b; padding: 0 1px; border-radius: 2px; }