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, useMemo } from 'react'; import { findNodeAndOffsetAtChar, getFlatOffset } from '#App [name="text-utils"]'; 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 const editorContextUtils = { // utilities (e.g. getContextInfo) can be plugged here later }; 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); // Autotype plugin system const [autotypePlugins, setAutotypePlugins] = useState([]); const [autotypeEnabled, setAutotypeEnabled] = useState(() => { try { return JSON.parse(localStorage.getItem('editor-autotype-enabled') || '{}'); } catch { return {}; } }); const [suggestions, setSuggestions] = useState([]); const pendingAutotypes = useRef(new Map()); const [activeSuggestionId, setActiveSuggestionId] = useState(null); const skipSelectionChange = useRef(false); /* ---- Rehydrate suggestions from saved DOM anchors on mount ---- */ useEffect(() => { const timer = setTimeout(() => { const root = editorRef.current; if (!root) return; const restored = []; root.querySelectorAll('.autotype-anchor').forEach(anchor => { if (!anchor.dataset.autotypeSuggestions) return; try { const list = JSON.parse(anchor.dataset.autotypeSuggestions); if (Array.isArray(list)) restored.push(...list); } catch (e) { // ignore malformed JSON } }); if (restored.length) { setSuggestions(prev => { const existingIds = new Set(prev.map(s => s.id)); const newOnes = restored.filter(s => !existingIds.has(s.id)); if (!newOnes.length) return prev; return [...prev, ...newOnes]; }); } }, 0); return () => clearTimeout(timer); }, []); // 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> groupLabels: new Map(), // prefix -> ref<groupLabel> disabledFns: new Map(), // id -> ref<disabled> disabledTitles: new Map(), // id -> ref<disabledTitle> }).current; const autotypeReg = useRef({ counts: new Map(), handlers: new Map(), params: new Map(), displayNames: new Map(), }).current; const registerAction = useCallback((id, labelRef, handlerRef, shouldShowRef, groupLabelRef, disabledRef, disabledTitleRef) => { 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); if (disabledRef) reg.disabledFns.set(id, disabledRef); if (disabledTitleRef) reg.disabledTitles.set(id, disabledTitleRef); if (groupLabelRef) { const prefix = id.substring(0, id.lastIndexOf('/')); if (prefix) { const existing = reg.groupLabels.get(prefix); if (groupLabelRef.current !== undefined || !existing) { reg.groupLabels.set(prefix, groupLabelRef); } } } 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); reg.disabledFns.delete(id); reg.disabledTitles.delete(id); setActions(prev => prev.filter(a => a.id !== id)); } else { reg.counts.set(id, count); } }; }, [reg]); const registerAutotypePlugin = useCallback((id, handlerRef, params, displayName) => { setAutotypePlugins(prev => { const count = (autotypeReg.counts.get(id) || 0) + 1; autotypeReg.counts.set(id, count); if (prev.some(p => p.id === id)) return prev; autotypeReg.handlers.set(id, handlerRef); if (params !== undefined) autotypeReg.params.set(id, params); if (displayName !== undefined) autotypeReg.displayNames.set(id, displayName); return [...prev, { id, displayName }]; }); return () => { const count = (autotypeReg.counts.get(id) || 1) - 1; if (count <= 0) { autotypeReg.counts.delete(id); autotypeReg.handlers.delete(id); autotypeReg.params.delete(id); autotypeReg.displayNames.delete(id); setAutotypePlugins(prev => prev.filter(p => p.id !== id)); } else { autotypeReg.counts.set(id, count); } }; }, [autotypeReg]); 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 ensureAnchor = useCallback((anchorId, suggestion) => { let anchor = document.getElementById(anchorId); // If anchor already exists, append suggestion metadata if provided if (anchor) { if (suggestion) { let existing = []; if (anchor.dataset.autotypeSuggestions) { try { existing = JSON.parse(anchor.dataset.autotypeSuggestions); } catch (e) { existing = []; } } if (!existing.some(s => s.id === suggestion.id)) { existing.push(suggestion); anchor.dataset.autotypeSuggestions = JSON.stringify(existing); } } return true; } const pending = pendingAutotypes.current.get(anchorId); if (!pending) return false; const { paragraphElement, startOffset, endOffset, sentenceText } = pending; const currentText = paragraphElement.textContent; const expectedSlice = currentText.slice(startOffset, endOffset).trim(); const sentenceTrimmed = sentenceText.trim(); let actualStart = startOffset; let actualEnd = endOffset; if (expectedSlice !== sentenceTrimmed) { const idx = currentText.indexOf(sentenceTrimmed); if (idx === -1) { pendingAutotypes.current.delete(anchorId); return false; } actualStart = idx; actualEnd = idx + sentenceTrimmed.length; } const startInfo = findNodeAndOffsetAtChar(paragraphElement, actualStart); const endInfo = findNodeAndOffsetAtChar(paragraphElement, actualEnd); if (!startInfo || !endInfo) { pendingAutotypes.current.delete(anchorId); return false; } const range = document.createRange(); range.setStart(startInfo.node, startInfo.offset); range.setEnd(endInfo.node, endInfo.offset); // Save current selection const sel = window.getSelection(); let savedStartFlat = null; let savedEndFlat = null; if (sel.rangeCount) { const currentRange = sel.getRangeAt(0); savedStartFlat = getFlatOffset(currentRange.startContainer, currentRange.startOffset, paragraphElement); savedEndFlat = getFlatOffset(currentRange.endContainer, currentRange.endOffset, paragraphElement); } const span = document.createElement('span'); span.className = 'autotype-anchor'; span.id = anchorId; span.dataset.autotypeId = anchorId; span.dataset.autotypeOriginal = sentenceTrimmed; if (suggestion) { span.dataset.autotypeSuggestions = JSON.stringify([suggestion]); } span.appendChild(range.extractContents()); range.insertNode(span); span.parentNode?.normalize(); // Restore selection to not disrupt caret if (savedStartFlat !== null) { const startInfo = findNodeAndOffsetAtChar(paragraphElement, savedStartFlat); const endInfo = findNodeAndOffsetAtChar(paragraphElement, savedEndFlat); if (startInfo && endInfo) { skipSelectionChange.current = true; const newRange = document.createRange(); newRange.setStart(startInfo.node, startInfo.offset); newRange.setEnd(endInfo.node, endInfo.offset); sel.removeAllRanges(); sel.addRange(newRange); setTimeout(() => { skipSelectionChange.current = false; }, 0); } } pendingAutotypes.current.delete(anchorId); return true; }, []); const dispatchSentenceCompletion = useCallback((sentenceText, anchorId, paragraphElement, startOffset, endOffset, trigger = 'auto') => { pendingAutotypes.current.set(anchorId, { sentenceText, paragraphElement, startOffset, endOffset, }); for (const p of autotypePlugins) { if (autotypeEnabled[p.id] === false) continue; const handler = autotypeReg.handlers.get(p.id)?.current; if (handler) { handler({ sentenceText, anchorId, paragraphElement, trigger }); } } }, [autotypePlugins, autotypeReg, autotypeEnabled]); const addSuggestion = useCallback((suggestion) => { if (suggestion.originalText?.trim() === suggestion.suggestedText?.trim()) return; if (!ensureAnchor(suggestion.anchorId, suggestion)) return; setSuggestions(prev => { if (prev.some(s => s.id === suggestion.id)) return prev; return [...prev, suggestion]; }); }, [ensureAnchor]); const removeSuggestion = useCallback((id) => { document.querySelectorAll('.autotype-anchor').forEach(anchor => { if (anchor.dataset.autotypeSuggestions) { try { const existing = JSON.parse(anchor.dataset.autotypeSuggestions); const idx = existing.findIndex(s => s.id === id); if (idx !== -1) { const updated = existing.filter(s => s.id !== id); if (updated.length > 0) { anchor.dataset.autotypeSuggestions = JSON.stringify(updated); } else { delete anchor.dataset.autotypeSuggestions; } } } catch (e) { // ignore } } }); setSuggestions(prev => prev.filter(s => s.id !== id)); }, []); const acceptSuggestion = useCallback((id) => { const s = suggestions.find(su => su.id === id); if (!s) return; const anchor = document.getElementById(s.anchorId); if (!anchor) return; const textNode = document.createTextNode(s.suggestedText); skipSelectionChange.current = true; anchor.replaceWith(textNode); textNode.parentNode?.normalize(); const sel = window.getSelection(); sel.removeAllRanges(); const caretRange = document.createRange(); caretRange.setStartAfter(textNode); caretRange.collapse(true); sel.addRange(caretRange); setTimeout(() => { skipSelectionChange.current = false; }, 0); setSuggestions(prev => prev.filter(su => su.id !== id)); setActiveSuggestionId(prev => prev === s.anchorId ? null : prev); }, [suggestions]); const rejectSuggestion = useCallback((id) => { const s = suggestions.find(su => su.id === id); if (!s) return; const anchor = document.getElementById(s.anchorId); if (!anchor) return; const textNode = document.createTextNode(s.originalText); skipSelectionChange.current = true; anchor.replaceWith(textNode); textNode.parentNode?.normalize(); const sel = window.getSelection(); sel.removeAllRanges(); const caretRange = document.createRange(); caretRange.setStartAfter(textNode); caretRange.collapse(true); sel.addRange(caretRange); setTimeout(() => { skipSelectionChange.current = false; }, 0); setSuggestions(prev => prev.filter(su => su.id !== id)); setActiveSuggestionId(prev => prev === s.anchorId ? null : prev); }, [suggestions]); const showSuggestion = useCallback((anchorId) => { setActiveSuggestionId(anchorId); }, []); const hideSuggestion = useCallback(() => { setActiveSuggestionId(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]); const getDisabled = useCallback((id) => reg.disabledFns.get(id)?.current, [reg]); const getDisabledTitle = useCallback((id) => reg.disabledTitles.get(id)?.current, [reg]); const getGroupLabel = useCallback((prefix) => reg.groupLabels.get(prefix)?.current, [reg]); const getAutotypeParams = useCallback((id) => autotypeReg.params.get(id), [autotypeReg]); const isAutotypeEnabled = useCallback((id) => { return autotypeEnabled[id] !== false; }, [autotypeEnabled]); const setAutotypeEnabledForId = useCallback((id, enabled) => { setAutotypeEnabled(prev => { const next = { ...prev, [id]: enabled }; try { localStorage.setItem('editor-autotype-enabled', JSON.stringify(next)); } catch { // ignore } return next; }); }, []); const getAutotypeSettings = useCallback((id) => { const params = autotypeReg.params.get(id); if (!params) return {}; const key = `editor-autotype-params-${id}`; let saved = {}; try { saved = JSON.parse(localStorage.getItem(key) || '{}'); } catch { saved = {}; } const settings = {}; for (const [k, spec] of Object.entries(params)) { settings[k] = saved[k] !== undefined ? saved[k] : spec.default; } return settings; }, [autotypeReg]); const ctxValue = useMemo(() => ({ editorRef, registerAction, openMenu, closeMenu, menu, actions, getLabel, getHandler, getShouldShow, getGroupLabel, getDisabled, getDisabledTitle, promptModal, openPromptModal, closePromptModal, registerAutotypePlugin, getAutotypeParams, getAutotypeSettings, dispatchSentenceCompletion, suggestions, addSuggestion, removeSuggestion, acceptSuggestion, rejectSuggestion, activeSuggestionId, showSuggestion, hideSuggestion, skipSelectionChange, autotypePlugins, isAutotypeEnabled, setAutotypeEnabledForId, }), [ editorRef, registerAction, openMenu, closeMenu, menu, actions, getLabel, getHandler, getShouldShow, getGroupLabel, getDisabled, getDisabledTitle, promptModal, openPromptModal, closePromptModal, registerAutotypePlugin, getAutotypeParams, getAutotypeSettings, dispatchSentenceCompletion, suggestions, addSuggestion, removeSuggestion, acceptSuggestion, rejectSuggestion, activeSuggestionId, showSuggestion, hideSuggestion, skipSelectionChange, autotypePlugins, autotypeEnabled, isAutotypeEnabled, setAutotypeEnabledForId, ]); return ( <EditorCtx.Provider value={ctxValue}> {children} </EditorCtx.Provider> ); } export function useContextMenuItem(id, label, handler, shouldShow, groupLabel, disabled, disabledTitle) { 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); const groupLabelRef = useRef(groupLabel); const disabledRef = useRef(disabled); const disabledTitleRef = useRef(disabledTitle); labelRef.current = label; handlerRef.current = handler; shouldShowRef.current = shouldShow; groupLabelRef.current = groupLabel; disabledRef.current = disabled; disabledTitleRef.current = disabledTitle; useEffect(() => { return registerAction(id, labelRef, handlerRef, shouldShowRef, groupLabelRef, disabledRef, disabledTitleRef); // Only the stable ID (and the stable registerAction) matter now. // Inline arrow functions in your plugins are safe. }, [id, registerAction]); } export function useAutotypePlugin(id, handler, params, displayName) { const { registerAutotypePlugin } = useEditor(); const handlerRef = useRef(handler); const paramsRef = useRef(params); handlerRef.current = handler; paramsRef.current = params; useEffect(() => { return registerAutotypePlugin(id, handlerRef, paramsRef.current, displayName); }, [id, registerAutotypePlugin, displayName]); } import React from 'react'; import { createRoot } from 'react-dom/client'; import { EditorProvider, useEditor, useContextMenuItem, useAutotypePlugin, } from '#App [name="editor-context"]'; import { TextEditor } from '#App [name="TextEditor"]'; import { PromptModal } from '#App [name="PromptModal"]'; import { DLLMReimaginePlugin } from '#App [name="contextmenu-dllm-reimagine"]'; import { SentenceLoggerPlugin, HighlightParagraphPlugin, } from '#App [name="contextmenu-debug"]'; import { AutotypeDebugPlugin, AutotypeLowercasePlugin, } from '#App [name="autotype-debug"]'; import { AutotypeLLMPlugin } from '#App [name="autotype-llm"]'; import { SentenceSuggestPopup } from '#App [name="SentenceSuggestPopup"]'; import { Sidebar, SetupPanel, AutotypePluginSetupPanels } from '#App [name="Sidebar"]'; function createDiffusionParams() { return { temp: { type: 'range', min: 0, max: 2, step: 0.1, default: 0.7, hint: 'Sampling temperature', }, steps: { type: 'range', min: 1, max: 196, step: 1, default: 128, 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: 32, hint: 'Block size for parallel decoding', }, seed: { type: 'range', min: 0, max: 999999, step: 1, default: 42, hint: 'Random seed', }, }; } window.availableModels = [ { name: "haic/llada2.1-mini-pruned128", params: createDiffusionParams() }, { name: "haic/qwen3-0.6b-diffusion-mdlm-v0.1", params: createDiffusionParams() }, ]; function loadActiveModel() { if (typeof localStorage !== 'undefined') { const savedName = localStorage.getItem('editor-active-model-name'); if (savedName) { const found = window.availableModels.find(m => m.name === savedName); if (found) return found; } } return window.availableModels[0]; } window.activeModel = loadActiveModel(); export function App() { return ( <EditorProvider> <div className="app-layout"> <TextEditor useEditor={useEditor} /> <Sidebar> <SetupPanel /> <AutotypePluginSetupPanels useEditor={useEditor} /> </Sidebar> </div> <DLLMReimaginePlugin useEditor={useEditor} useContextMenuItem={useContextMenuItem} /> <SentenceLoggerPlugin useContextMenuItem={useContextMenuItem} /> <HighlightParagraphPlugin useContextMenuItem={useContextMenuItem} /> <AutotypeLLMPlugin useEditor={useEditor} useAutotypePlugin={useAutotypePlugin} /> <SentenceSuggestPopup useEditor={useEditor} /> <PromptModal useEditor={useEditor} /> <div className="prototype-overlay"> Prototype! <div className="prototype-overlay__sub">Use at own risk</div> </div> </EditorProvider> ); } .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; } } } import React, { useState, useEffect } from 'react'; 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 BlockButton({ tag, label, title }) { const [tick, setTick] = useState(0); useEffect(() => { const onSel = () => setTick(t => t + 1); document.addEventListener('selectionchange', onSel); return () => document.removeEventListener('selectionchange', onSel); }, []); const blockValue = () => { try { return (document.queryCommandValue('formatBlock') || '').toString().toLowerCase(); } catch { return ''; } }; 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> ); } export function FormattingToolbar({ 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 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> ); } import React, { useEffect } from 'react'; import * as utils from '#App [name="text-utils"]'; /* ============================================================ ContextMenu helpers ============================================================ */ function buildMenuTree(actions, getLabel, getHandler, getShouldShow, getGroupLabel, getDisabled, getDisabledTitle, context) { const root = { items: [] }; for (const a of actions) { const shouldShow = getShouldShow(a.id); if (shouldShow && !shouldShow(context)) continue; const parts = a.id.split('/'); let current = root; let parentGroupNode = null; for (let i = 0; i < parts.length; i++) { const part = parts[i]; const isLeaf = i === parts.length - 1; let node = current.items.find(n => n.part === part); if (!node) { node = { part, items: [] }; current.items.push(node); } if (isLeaf) { node.id = a.id; node.label = getLabel(a.id); node.handler = getHandler(a.id); const disabledFn = getDisabled ? getDisabled(a.id) : undefined; node.disabled = typeof disabledFn === 'function' ? disabledFn(context) : !!disabledFn; node.disabledTitle = getDisabledTitle ? getDisabledTitle(a.id) : undefined; // look up custom group label and attach to the parent group node if (parts.length > 1 && parentGroupNode) { const prefix = a.id.substring(0, a.id.lastIndexOf('/')); const groupLabel = getGroupLabel(prefix); if (groupLabel) { parentGroupNode.groupLabel = groupLabel; } } } parentGroupNode = node; current = node; } } function prune(nodes) { const out = []; for (const n of nodes) { if (!n.id) { // group node — prune children n.items = prune(n.items); if (n.items.length === 0) continue; } out.push(n); } return out; } return prune(root.items); } function titleCase(str) { return str.replace(/[-_]/g, ' ').replace(/\b\w/g, c => c.toUpperCase()); } function MenuLevel({ nodes, context, closeMenu, depth = 0 }) { if (nodes.length === 0) return null; const children = nodes.map(node => { if (node.items && node.items.length > 0) { return ( <div key={node.part} className="context-menu__item context-menu__item--group"> <span>{node.label || node.groupLabel || titleCase(node.part)}</span> <span className="context-menu__chevron">›</span> <MenuLevel nodes={node.items} context={context} closeMenu={closeMenu} depth={depth + 1} /> </div> ); } if (node.disabled) { return ( <div key={node.id} className="context-menu__item context-menu__item--disabled" title={node.disabledTitle ?? ''} > {node.label} </div> ); } return ( <div key={node.id} className="context-menu__item" onClick={() => { node.handler?.(context); closeMenu(); }} > {node.label} </div> ); }); if (depth === 0) return <>{children}</>; return <div className="context-menu__submenu">{children}</div>; } /* ============================================================ ContextMenu ============================================================ */ export function ContextMenu({ useEditor }) { const { menu, closeMenu, actions, getLabel, getHandler, getShouldShow, getGroupLabel, getDisabled, getDisabledTitle } = useEditor(); /* Defensive: old provider builds may not expose these getters yet. */ const safeGetDisabled = getDisabled || (() => undefined); const safeGetDisabledTitle = getDisabledTitle || (() => undefined); 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 tree = buildMenuTree(actions, getLabel, getHandler, getShouldShow, getGroupLabel, safeGetDisabled, safeGetDisabledTitle, context); if (tree.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 - tree.length * 36), }} onClick={e => e.stopPropagation()} onMouseDown={e => e.preventDefault()} > <MenuLevel nodes={tree} context={context} closeMenu={closeMenu} depth={0} /> </div> ); } .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; } &--disabled { color: #888; cursor: not-allowed; background: transparent; &:hover { background: transparent; } } &--group { position: relative; display: flex; align-items: center; justify-content: space-between; padding-right: 12px; &:hover > .context-menu__submenu { display: block; } } } &__submenu { position: absolute; top: -4px; left: calc(100% - 4px); 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; display: none; z-index: 10001; /* Invisible hover bridge so the cursor can move from parent item into submenu without traversing a dead-zone gap. */ &::before { content: ''; position: absolute; top: 0; left: -8px; width: 8px; height: 100%; } } &__chevron { font-size: 16px; color: #888; margin-left: 8px; } } import React, { useRef, useEffect } from 'react'; import * as utils from '#App [name="text-utils"]'; import { FormattingToolbar } from '#App [name="FormattingToolbar"]'; import { ContextMenu } from '#App [name="ContextMenu"]'; /* ============================================================ TextEditor ============================================================ */ export function TextEditor({ useEditor, defaultValue }) { const { editorRef, openMenu, closeMenu, dispatchSentenceCompletion, showSuggestion, hideSuggestion, skipSelectionChange, removeSuggestion, suggestions } = useEditor(); const forcedAnchorIds = useRef(new Set()); 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); }; }, []); /* ---- Track caret inside autotype anchors ---- */ useEffect(() => { const el = surfaceRef.current; if (!el) return; const handleSelectionChange = () => { if (skipSelectionChange.current) return; const sel = window.getSelection(); if (!sel.rangeCount) { hideSuggestion(); return; } const range = sel.getRangeAt(0); if (!el.contains(range.commonAncestorContainer)) { hideSuggestion(); return; } const node = range.startContainer; const element = node.nodeType === Node.TEXT_NODE ? node.parentElement : node; const anchor = element?.closest?.('.autotype-anchor'); console.log('[Popup debug] selectionchange anchor:', anchor?.id ?? null, 'element:', element?.nodeName ?? null); if (anchor) { showSuggestion(anchor.id); } else { hideSuggestion(); } }; document.addEventListener('selectionchange', handleSelectionChange); return () => document.removeEventListener('selectionchange', handleSelectionChange); }, [showSuggestion, hideSuggestion]); /* ---- Auto-show popup for forced completions ---- */ useEffect(() => { const el = surfaceRef.current; if (!el) return; const pending = Array.from(forcedAnchorIds.current); for (const id of pending) { const anchor = document.getElementById(id); if (!anchor || !el.contains(anchor)) { forcedAnchorIds.current.delete(id); continue; } showSuggestion(anchor.id); forcedAnchorIds.current.delete(id); } }, [suggestions, showSuggestion]); /* ---- Unwrap autotype anchor on direct typing ---- */ useEffect(() => { const el = surfaceRef.current; if (!el) return; const handleBeforeInput = (e) => { if (!e.inputType || !e.inputType.startsWith('insert')) return; const sel = window.getSelection(); if (!sel.rangeCount) return; const range = sel.getRangeAt(0); const node = range.startContainer; const element = node.nodeType === Node.TEXT_NODE ? node.parentElement : node; const anchor = element?.closest?.('.autotype-anchor'); if (!anchor) return; const idsToRemove = utils.unwrapAutotypeAnchor(anchor); idsToRemove.forEach(id => removeSuggestion(id)); }; el.addEventListener('beforeinput', handleBeforeInput); return () => el.removeEventListener('beforeinput', handleBeforeInput); }, [removeSuggestion]); /* ---- Sentence completion trigger ---- */ useEffect(() => { const el = surfaceRef.current; if (!el) return; const handleInput = (e) => { console.log('[Autotype trace] inputType=', e.inputType, 'data=', JSON.stringify(e.data), 'target=', e.target.nodeName); if (e.inputType !== 'insertText' && e.inputType !== 'insertParagraph' && e.inputType !== 'insertLineBreak') { console.log('[Autotype trace] ⛔ ignored: wrong inputType'); return; } const sel = window.getSelection(); if (!sel.rangeCount) { console.log('[Autotype trace] ⛔ ignored: no selection'); return; } const range = sel.getRangeAt(0); let sentence = null; if (e.inputType === 'insertParagraph' || e.inputType === 'insertLineBreak') { sentence = utils.getLastCompletedSentence(range, el, true); console.log('[Autotype trace] paragraph break sentence=', sentence); } else if (e.inputType === 'insertText' && e.data === ' ') { const block = utils.getParagraphElement(range.endContainer, el); if (!block) { console.log('[Autotype trace] ⛔ ignored: no block'); return; } const caretFlat = utils.getFlatOffset(range.endContainer, range.endOffset, block); const trimmedLen = block.textContent.trimEnd().length; console.log('[Autotype trace] caretFlat=', caretFlat, 'trimmedLen=', trimmedLen, 'blockText=', JSON.stringify(block.textContent)); if (caretFlat < trimmedLen) { console.log('[Autotype trace] ⛔ ignored: caret not at end'); return; } if (caretFlat >= 2) { const precedingChar = block.textContent[caretFlat - 2]; console.log('[Autotype trace] precedingChar=', JSON.stringify(precedingChar)); if ('.!?'.includes(precedingChar)) { sentence = utils.getLastCompletedSentence(range, el, false); console.log('[Autotype trace] sentence after space=', sentence); } else { console.log('[Autotype trace] ⛔ ignored: precedingChar not sentence terminator'); } } else { console.log('[Autotype trace] ⛔ ignored: caretFlat < 2'); } } else { console.log('[Autotype trace] ⛔ ignored: insertText without space'); } if (!sentence) { console.log('[Autotype trace] ⛔ ignored: no sentence found'); return; } if (utils.isInsideAutotypeAnchor(sentence.range.startContainer)) { console.log('[Autotype trace] ⛔ ignored: inside existing autotype-anchor'); return; } const anchorId = `autotype-${Date.now()}-${Math.random().toString(36).slice(2)}`; const startOffset = utils.getFlatOffset(sentence.range.startContainer, sentence.range.startOffset, sentence.container); const endOffset = utils.getFlatOffset(sentence.range.endContainer, sentence.range.endOffset, sentence.container); setTimeout(() => { dispatchSentenceCompletion(sentence.text, anchorId, sentence.container, startOffset, endOffset, 'auto'); }, 0); }; el.addEventListener('input', handleInput); return () => el.removeEventListener('input', handleInput); }, [dispatchSentenceCompletion]); /* ---- Manual sentence completion trigger ---- */ useEffect(() => { const el = surfaceRef.current; if (!el) return; const handleKeyDown = (e) => { const isTrigger = (e.ctrlKey || e.metaKey) && e.code === 'Space'; if (!isTrigger) return; e.preventDefault(); const sel = window.getSelection(); if (!sel.rangeCount) return; const range = sel.getRangeAt(0); if (utils.isInsideAutotypeAnchor(range.startContainer)) return; const sentence = utils.getSentenceAtCaret(range, el); if (!sentence || utils.isInsideAutotypeAnchor(sentence.range.startContainer)) return; const anchorId = `autotype-${Date.now()}-${Math.random().toString(36).slice(2)}`; const startOffset = utils.getFlatOffset(sentence.range.startContainer, sentence.range.startOffset, sentence.container); const endOffset = utils.getFlatOffset(sentence.range.endContainer, sentence.range.endOffset, sentence.container); forcedAnchorIds.current.add(anchorId); dispatchSentenceCompletion(sentence.text, anchorId, sentence.container, startOffset, endOffset, 'manual'); }; el.addEventListener('keydown', handleKeyDown); return () => el.removeEventListener('keydown', handleKeyDown); }, [dispatchSentenceCompletion]); 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) => { const idsToRemove = utils.replaceContextRange(ctxInfo, newText); idsToRemove.forEach(id => removeSuggestion(id)); }, }; openMenu(e.clientX, e.clientY, context); }; return ( <div className="editor-page"> <FormattingToolbar 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> ); } .editor-page { background: #e4e4e4; flex: 1; min-height: 0; overflow-y: auto; } .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; /* Persisted selection highlight when context-menu is open */ mark.editor-selection-persist { background: #b4d7ff; color: inherit; } } import React, { useState, useEffect, useRef } from 'react'; import { getInitialValues } from '#App [name="Sidebar"]'; /* ------------------------------------------------------------ Diff helpers ------------------------------------------------------------ */ 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; } function trimInvisible(str) { return str.replace(/^[\s\p{Cc}\p{Cf}]+|[\s\p{Cc}\p{Cf}]+$/gu, ''); } /* ------------------------------------------------------------ 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(() => getInitialValues(modelParams, activeModel.name)); const [pluginSettings, setPluginSettings] = useState(() => buildInitialSettings(pluginParams)); const [outputText, setOutputText] = useState(''); const [isLoading, setIsLoading] = useState(false); const [error, setError] = useState(null); const promptDelayRef = useRef(100); // Ensure settings always contain valid defaults so inputs are never uncontrolled const effectiveModelSettings = React.useMemo( () => ({ ...getInitialValues(modelParams, activeModel.name), ...modelSettings }), [modelParams, activeModel.name, modelSettings] ); const effectivePluginSettings = React.useMemo( () => ({ ...buildInitialSettings(pluginParams), ...pluginSettings }), [pluginParams, pluginSettings] ); // Reset when modal opens useEffect(() => { if (!isOpen) return; setModelSettings(getInitialValues(modelParams, activeModel.name)); setPluginSettings(buildInitialSettings(pluginParams)); setOutputText(''); setError(null); promptDelayRef.current = 100; }, [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); }); }, promptDelayRef.current); 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 ''; let extraRanges = []; if (typeof modalContext.inputHighlights === 'function') { try { extraRanges = modalContext.inputHighlights({ plugin: effectivePluginSettings }, modalContext) || []; } catch (e) { /* ignore */ } } const boundaries = new Set([0, text.length, selectionStart, selectionEnd]); for (const r of extraRanges) { boundaries.add(Math.max(0, Math.min(r.start, text.length))); boundaries.add(Math.max(0, Math.min(r.end, text.length))); } const points = Array.from(boundaries).sort((a, b) => a - b); let html = '<div class="prompt-modal__input-body">'; for (let i = 0; i < points.length - 1; i++) { const segStart = points[i]; const segEnd = points[i + 1]; if (segStart === segEnd) continue; const inSelection = segStart >= selectionStart && segEnd <= selectionEnd; const inContext = !inSelection && extraRanges.some(r => segStart >= r.start && segEnd <= r.end); const segText = escapeHtml(text.slice(segStart, segEnd)); if (inSelection) { html += `<span class="highlight-selection">${segText}</span>`; } else if (inContext) { html += `<span class="highlight-context">${segText}</span>`; } else { html += segText; } } html += '</div>'; return html; }; const buildOutputHtml = () => { if (!outputText) return ''; const normalizedOutput = trimInvisible(outputText); const dmp = new diff_match_patch(); const diffs = dmp.diff_main(text, normalizedOutput); console.log("Diff", text, normalizedOutput, diffs); // Mark which positions in normalizedOutput came from additions (type 1) const addedMask = new Array(normalizedOutput.length).fill(false); let pos = 0; for (const entry of diffs) { const type = entry[0]; const diffText = entry[1]; if (type === -1 || diffText.trim().length==0) continue; if (type === 1) { for (let i = 0; i < diffText.length; i++) { addedMask[pos + i] = true; } } pos += diffText.length; } // Walk through normalizedOutput word-by-word (whitespace-separated) let html = '<div class="prompt-modal__output-body">'; let i = 0; while (i < normalizedOutput.length) { if (/\s/.test(normalizedOutput[i])) { let ws = ''; while (i < normalizedOutput.length && /\s/.test(normalizedOutput[i])) { ws += normalizedOutput[i]; i++; } html += escapeHtml(ws); } else { let word = ''; let hasAddition = false; while (i < normalizedOutput.length && !/\s/.test(normalizedOutput[i])) { if (addedMask[i]) hasAddition = true; word += normalizedOutput[i]; i++; } const escaped = escapeHtml(word); html += hasAddition ? `<span class="highlight-diff">${escaped}</span>` : escaped; } } html += '</div>'; return html; }; const inputHtml = buildInputHtml(); const outputDisplayHtml = buildOutputHtml(); const handleAccept = () => { if (isLoading || !outputText) return; replace(trimInvisible(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) => { promptDelayRef.current = v.type === 'text' ? 750 : 100; 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) => { promptDelayRef.current = 100; 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</div> <div className={`prompt-modal__doc-piece prompt-modal__doc-piece--output${isLoading ? ' prompt-modal__doc-piece--output-loading' : ''}`}> {isLoading && ( <div className="prompt-modal__loading-overlay"> <div className="prompt-modal__loading-icon">⏳</div> </div> )} {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> ); } .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); } } &__doc-piece--output-loading { opacity: 0.3; } &__loading-overlay { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; z-index: 2; pointer-events: none; } &__loading-icon { font-size: 48px; line-height: 1; } &__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; } } } &__error { color: #c5221f; font-size: 13px; background: #fce8e8; padding: 8px 10px; border-radius: 4px; border: 1px solid #f5c2c7; } /* 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; } .highlight-context { background: #f0f0e0; padding: 0 2px; border-radius: 2px; } } /* 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; } } import React, { useEffect, useState } from 'react'; /* ------------------------------------------------------------ Diff helpers ------------------------------------------------------------ */ function escapeHtml(str) { return str .replace(/&/g, '&') .replace(/</g, '<') .replace(/>/g, '>'); } function buildDiffHtml(original, suggested) { if (!window.diff_match_patch) { return escapeHtml(suggested); } try { const dmp = new diff_match_patch(); const diffs = dmp.diff_main(original, suggested); if (!Array.isArray(diffs)) { return escapeHtml(suggested); } dmp.diff_cleanupSemantic(diffs); // Mark which positions in suggested came from additions (type === 1) const addedMask = new Array(suggested.length).fill(false); let pos = 0; for (let j = 0; j < diffs.length; j++) { const type = diffs[j][0]; const text = diffs[j][1]; if (typeof text !== 'string') continue; if (type === -1) continue; if (type === 1) { for (let i = 0; i < text.length; i++) { addedMask[pos + i] = true; } } pos += text.length; } let html = ''; let i = 0; while (i < suggested.length) { if (/\s/.test(suggested[i])) { let ws = ''; while (i < suggested.length && /\s/.test(suggested[i])) { ws += suggested[i]; i++; } html += escapeHtml(ws); } else { let word = ''; let hasAddition = false; while (i < suggested.length && !/\s/.test(suggested[i])) { if (addedMask[i]) hasAddition = true; word += suggested[i]; i++; } const escaped = escapeHtml(word); html += hasAddition ? `<span class="highlight-diff">${escaped}</span>` : escaped; } } return html; } catch (err) { console.error('buildDiffHtml error:', err); return escapeHtml(suggested); } } /* ------------------------------------------------------------ SentenceSuggestPopup – fixed-position margin suggestions ------------------------------------------------------------ */ export function SentenceSuggestPopup({ useEditor }) { const { suggestions, activeSuggestionId, acceptSuggestion, rejectSuggestion } = useEditor(); const [positions, setPositions] = useState({}); useEffect(() => { function updatePositions() { const next = {}; for (const s of suggestions) { const el = document.getElementById(s.anchorId); if (el) { const rect = el.getBoundingClientRect(); next[s.id] = { top: rect.top, right: rect.right, left: rect.left, bottom: rect.bottom, height: rect.height, width: rect.width, }; } } setPositions(next); } updatePositions(); window.addEventListener('scroll', updatePositions, true); window.addEventListener('resize', updatePositions); return () => { window.removeEventListener('scroll', updatePositions, true); window.removeEventListener('resize', updatePositions); }; }, [suggestions]); const visible = suggestions.filter(s => s.anchorId === activeSuggestionId); console.log('[Popup debug] suggestions.length=', suggestions.length, 'activeId=', activeSuggestionId, 'visible=', visible.map(v => ({ id: v.id, anchorId: v.anchorId }))); if (!visible.length) return null; return ( <> {visible.map((s) => { let pos = positions[s.id]; if (!pos) { const el = document.getElementById(s.anchorId); if (el) { const r = el.getBoundingClientRect(); pos = { top: r.top, right: r.right, left: r.left, bottom: r.bottom, height: r.height, width: r.width }; console.log('[Popup debug] fallback pos computed for', s.id, s.anchorId); } else { console.log('[Popup debug] missing position + no DOM el for', s.id, s.anchorId); } } if (!pos) return null; return ( <div key={s.id} className="sentence-suggest-popup" style={{ position: 'fixed', top: pos.top, left: pos.right + 12, zIndex: 10000, maxWidth: 260, }} > <div className="sentence-suggest-popup__body" dangerouslySetInnerHTML={{ __html: buildDiffHtml(s.originalText, s.suggestedText), }} /> <div className="sentence-suggest-popup__actions"> <button className="sentence-suggest-popup__btn sentence-suggest-popup__btn--accept" onClick={() => acceptSuggestion(s.id)} > Accept </button> <button className="sentence-suggest-popup__btn sentence-suggest-popup__btn--reject" onClick={() => rejectSuggestion(s.id)} > Reject </button> </div> </div> ); })} </> ); } /* ------------------------------------------------------------ Autotype – inline anchors + sentence suggestion overlays ------------------------------------------------------------ */ .autotype-anchor { border-bottom: 1.5px dashed rgba(26, 115, 232, 0.45); transition: border-color 0.2s ease; &:hover { border-bottom-color: rgba(26, 115, 232, 0.85); } } .sentence-suggest-popup { background: #fff; border: 1px solid #ddd; border-radius: 6px; box-shadow: 0 4px 16px rgba(0, 0, 0, 0.12); padding: 10px 12px; font-family: Georgia, "Times New Roman", serif; font-size: 14px; line-height: 1.5; color: #222; pointer-events: auto; &__body { margin-bottom: 8px; white-space: pre-wrap; overflow-wrap: break-word; } &__actions { display: flex; gap: 6px; justify-content: flex-end; } &__btn { padding: 5px 12px; border-radius: 4px; font-size: 13px; font-family: system-ui, sans-serif; cursor: pointer; border: 1px solid transparent; transition: background 0.15s ease, border-color 0.15s ease; &--accept { background: #1a73e8; color: #fff; border-color: #1a73e8; &:hover { background: #1557b0; } } &--reject { background: #fff; color: #555; border-color: #ccc; &:hover { background: #f5f5f5; } } } } .highlight-diff { background: #ffeb3b; padding: 0 1px; border-radius: 2px; } .sidebar { position: relative; display: flex; flex-direction: column; border-left: 1px solid #ccc; background: #fff; flex-shrink: 0; box-sizing: border-box; height: 100vh; width: 0; padding: 0; overflow: hidden; transition: width 0.25s ease, padding 0.25s ease; &--expanded { width: 280px; padding: 12px; } &__close { align-self: flex-end; min-width: 28px; height: 28px; border: 1px solid #ccc; border-radius: 4px; background: #fff; cursor: pointer; display: inline-flex; align-items: center; justify-content: center; color: #333; .material-icons-outlined { font-size: 20px; } &:hover { background: #f3f3f3; } } &__content { margin-top: 8px; overflow-y: auto; overflow-x: hidden; flex: 1; min-height: 0; } &__footer { border-top: 1px solid #e0e0e0; padding-top: 12px; margin-top: 8px; flex-shrink: 0; } &__reset-btn { width: 100%; padding: 6px 10px; border: 1px solid #ccc; border-radius: 4px; background: #fff; font-family: system-ui, sans-serif; font-size: 12px; color: #333; cursor: pointer; &:hover { background: #f3f3f3; } &--danger { border-color: #e74c3c; color: #e74c3c; &:hover { background: #fdf1f0; } } &--secondary { border-color: #ccc; color: #666; &:hover { background: #f3f3f3; } } } &__reset-warning { display: flex; flex-direction: column; gap: 8px; } &__reset-note { margin: 0; font-family: system-ui, sans-serif; font-size: 11px; color: #e74c3c; line-height: 1.4; } &__reset-actions { display: flex; flex-direction: column; gap: 6px; } &__floating-toggle { position: absolute; bottom: 12px; right: 12px; z-index: 100; width: 32px; height: 32px; border: none; border-radius: 4px; background: transparent; cursor: pointer; display: inline-flex; align-items: center; justify-content: center; color: #666; opacity: 0.7; &:hover { opacity: 1; color: #333; } .material-icons-outlined { font-size: 20px; } } } .setup-panel { &__header { display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 16px; gap: 8px; } &__title { margin: 0 0 4px; padding-bottom: 8px; border-bottom: 1px solid #e0e0e0; font-family: system-ui, sans-serif; font-size: 14px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.5px; color: #333; } & + & { .setup-panel__title { margin-top: 16px; } } &__model { font-family: monospace; font-size: 11px; color: #666; word-break: break-all; } &__toggle { display: inline-flex; align-items: center; gap: 4px; font-family: system-ui, sans-serif; font-size: 12px; color: #333; cursor: pointer; white-space: nowrap; flex-shrink: 0; input[type="checkbox"] { accent-color: #333; cursor: pointer; } } &__params { display: flex; flex-direction: column; gap: 12px; } &__param { display: grid; grid-template-columns: 1fr 1.5fr; gap: 8px; align-items: start; border-radius: 4px; min-width: 0; & > * { min-width: 0; } } &__label-row { display: flex; align-items: center; gap: 6px; background: #e8eaed; padding: 4px 6px; border-radius: 3px; align-self: stretch; } &__label { font-family: system-ui, sans-serif; font-size: 12px; font-weight: 600; color: #333; text-transform: capitalize; cursor: default; flex: 1; } &__range { display: flex; align-items: center; gap: 8px; input[type="range"] { flex: 1; min-width: 0; accent-color: #333; } } &__value { font-family: monospace; font-size: 12px; color: #333; min-width: 36px; text-align: right; } &__select { width: 100%; padding: 4px 8px; border: 1px solid #ccc; border-radius: 4px; background: #fff; font-family: system-ui, sans-serif; font-size: 12px; color: #333; cursor: pointer; &:focus { outline: 2px solid rgba(0, 0, 0, 0.1); outline-offset: 1px; } } &__textarea { width: 100%; padding: 6px 8px; border: 1px solid #ccc; border-radius: 4px; background: #fff; font-family: system-ui, sans-serif; font-size: 12px; color: #333; resize: vertical; box-sizing: border-box; &:focus { outline: 2px solid rgba(0, 0, 0, 0.1); outline-offset: 1px; } } } import React, { useState, useEffect, useCallback } from 'react'; function clearAllStorage() { if (typeof localStorage !== 'undefined') { localStorage.clear(); } window.location.reload(); } function ResetButton() { const [warned, setWarned] = useState(false); if (!warned) { return ( <button type="button" className="sidebar__reset-btn" onClick={() => setWarned(true)} > Reset editor and settings </button> ); } return ( <div className="sidebar__reset-warning"> <p className="sidebar__reset-note"> This will delete your document and reset all settings. Proceed? </p> <div className="sidebar__reset-actions"> <button type="button" className="sidebar__reset-btn sidebar__reset-btn--danger" onClick={clearAllStorage} > Yes, reset everything </button> <button type="button" className="sidebar__reset-btn sidebar__reset-btn--secondary" onClick={() => setWarned(false)} > Cancel </button> </div> </div> ); } function getStorageKey(modelKey) { return `editor-model-params-${modelKey}`; } export function getInitialValues(params, modelKey = 'activeModel') { const key = getStorageKey(modelKey); try { const saved = JSON.parse(localStorage.getItem(key) || '{}'); const values = {}; for (const [keyName, spec] of Object.entries(params)) { values[keyName] = saved[keyName] !== undefined ? saved[keyName] : spec.default; } return values; } catch { return Object.fromEntries( Object.entries(params).map(([keyName, spec]) => [keyName, spec.default]) ); } } function saveValues(values, modelKey = 'activeModel') { const key = getStorageKey(modelKey); try { localStorage.setItem(key, JSON.stringify(values)); } catch { // ignore storage errors } } /** * SetupPanel – parameter configurator. * * Usage modes: * 1) SetupPanel() – reads model from window.activeModel * 2) SetupPanel({ modelKey }) – reads model from window[modelKey] * 3) SetupPanel({ title, params, storageKey }) – explicit mode (for plugins) */ export function SetupPanel({ modelKey, title: explicitTitle, params: explicitParams, storageKey: explicitStorageKey, topRow }) { const isDefaultMode = !explicitParams && !modelKey; const [selectedModelName, setSelectedModelName] = useState(() => { if (isDefaultMode) return window.activeModel?.name || ''; return ''; }); const model = !explicitParams ? (isDefaultMode ? window.availableModels?.find(m => m.name === selectedModelName) || window.activeModel : window[modelKey]) : null; if (!model && !explicitParams) return null; const title = explicitTitle || model?.name || 'Setup'; const params = explicitParams || model?.params || {}; const effectiveModelKey = isDefaultMode ? (selectedModelName || 'activeModel') : (modelKey || 'activeModel'); const storageKey = explicitStorageKey || getStorageKey(effectiveModelKey); const [values, setValues] = useState(() => getInitialValues(params, effectiveModelKey)); useEffect(() => { setValues(getInitialValues(params, effectiveModelKey)); }, [params, storageKey, effectiveModelKey]); const updateValue = useCallback((key, nextValue) => { setValues(prev => { const updated = { ...prev, [key]: nextValue }; try { localStorage.setItem(storageKey, JSON.stringify(updated)); } catch { // ignore storage errors } return updated; }); }, [storageKey]); return ( <div className="setup-panel"> <div className="setup-panel__header"> <div> {isDefaultMode ? ( <h3 className="setup-panel__title">Text-Diffusion Model</h3> ) : ( <> <h3 className="setup-panel__title">{title}</h3> {model?.name && <div className="setup-panel__model">{model.name}</div>} </> )} </div> </div> <div className="setup-panel__params"> {isDefaultMode && window.availableModels?.length > 1 && ( <div className="setup-panel__param"> <div className="setup-panel__label-row"> <label className="setup-panel__label">model</label> </div> <select className="setup-panel__select" value={selectedModelName} onChange={(e) => { const newName = e.target.value; setSelectedModelName(newName); const newModel = window.availableModels.find(m => m.name === newName); if (newModel) { window.activeModel = newModel; localStorage.setItem('editor-active-model-name', newName); } }} > {window.availableModels.map(m => ( <option key={m.name} value={m.name}>{m.name}</option> ))} </select> </div> )} {topRow} {Object.entries(params).map(([key, spec]) => ( <div key={key} className="setup-panel__param" title={spec.hint || undefined}> <div className="setup-panel__label-row"> <label className="setup-panel__label" htmlFor={`${storageKey}-${key}`}> {key} </label> </div> {spec.type === 'range' && ( <div className="setup-panel__range"> <input id={`${storageKey}-${key}`} type="range" min={spec.min} max={spec.max} step={spec.step} value={values[key]} onChange={e => updateValue(key, e.target.valueAsNumber)} /> <span className="setup-panel__value">{values[key]}</span> </div> )} {spec.type === 'enum' && ( <select id={`${storageKey}-${key}`} className="setup-panel__select" value={values[key]} onChange={e => updateValue(key, e.target.value)} > {spec.values.map(opt => ( <option key={opt.value} value={opt.value}> {opt.title} </option> ))} </select> )} {spec.type === 'text' && ( <textarea id={`${storageKey}-${key}`} className="setup-panel__textarea" value={values[key]} rows={spec.rows || 4} placeholder={spec.placeholder || ''} onChange={e => updateValue(key, e.target.value)} /> )} </div> ))} </div> </div> ); } export function AutotypePluginSetupPanels({ useEditor }) { const { autotypePlugins, getAutotypeParams, isAutotypeEnabled, setAutotypeEnabledForId } = useEditor(); return ( <> {autotypePlugins.map(plugin => { const params = getAutotypeParams(plugin.id) || {}; const enabled = isAutotypeEnabled(plugin.id); return ( <SetupPanel key={plugin.id} title={plugin.displayName || plugin.id} params={params} storageKey={`editor-autotype-params-${plugin.id}`} topRow={ <div className="setup-panel__param"> <div className="setup-panel__label-row"> <label className="setup-panel__label">Enabled</label> </div> <label className="setup-panel__toggle"> <input type="checkbox" checked={enabled} onChange={(e) => setAutotypeEnabledForId(plugin.id, e.target.checked)} /> </label> </div> } /> ); })} </> ); } export function Sidebar({ children }) { const STORAGE_KEY = "editor-sidebar-collapsed"; const [isCollapsed, setIsCollapsed] = useState(() => { try { const stored = localStorage.getItem(STORAGE_KEY); return stored === null ? true : stored === "true"; } catch { return true; } }); useEffect(() => { try { localStorage.setItem(STORAGE_KEY, String(isCollapsed)); } catch { // silently ignore storage errors } }, [isCollapsed]); const toggle = () => setIsCollapsed((prev) => !prev); return ( <> {isCollapsed && ( <button type="button" className="sidebar__floating-toggle" onClick={toggle} title="Expand sidebar" > <span className="material-icons-outlined">settings</span> </button> )} <aside className={`sidebar ${!isCollapsed ? 'sidebar--expanded' : ''}`}> <button type="button" className="sidebar__close" onClick={toggle} title="Collapse sidebar" > <span className="material-icons-outlined">close</span> </button> <div className="sidebar__content">{children}</div> <div className="sidebar__footer"> <ResetButton /> </div> </aside> </> ); } // ------------------------------------------------------------ // 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 // ------------------------------------------------------------ const BLOCK_TAGS = new Set([ 'p', 'li', 'div', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'blockquote', 'section', 'article', 'pre', 'header', 'footer', ]); function getBlockParent(node, ancestor) { let el = node.nodeType === Node.TEXT_NODE ? node.parentElement : node; while (el && el !== ancestor) { if (BLOCK_TAGS.has(el.tagName?.toLowerCase())) return el; el = el.parentElement; } return null; } export function getFlatOffset(targetNode, targetOffset, ancestor) { let count = 0; const walker = document.createTreeWalker(ancestor, NodeFilter.SHOW_TEXT); let prevBlock = null; let n; while ((n = walker.nextNode())) { const block = getBlockParent(n, ancestor); if (prevBlock && block !== prevBlock) { count += 1; // newline between blocks } if (n === targetNode) return count + targetOffset; count += n.textContent.length; prevBlock = block; } 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 prevBlock = null; let n; while ((n = walker.nextNode())) { const block = getBlockParent(n, paragraphElement); if (prevBlock && block !== prevBlock) { count += 1; // newline between blocks if (count >= charOffset) { return { node: n, offset: 0 }; } } const len = n.textContent.length; if (count + len >= charOffset) { return { node: n, offset: charOffset - count }; } count += len; prevBlock = block; } // 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) { let idsToRemove = []; const anchor = getAutotypeAnchor(range.commonAncestorContainer); if (anchor) { const scope = anchor.parentElement; const startFlat = getFlatOffset(range.startContainer, range.startOffset, scope); const endFlat = getFlatOffset(range.endContainer, range.endOffset, scope); idsToRemove.push(...unwrapAutotypeAnchor(anchor)); const startInfo = findNodeAndOffsetAtChar(scope, startFlat); const endInfo = findNodeAndOffsetAtChar(scope, endFlat); if (startInfo && endInfo) { range = document.createRange(); range.setStart(startInfo.node, startInfo.offset); range.setEnd(endInfo.node, endInfo.offset); } } // 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); return idsToRemove; } 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. Sentence completion helper // ------------------------------------------------------------ // ------------------------------------------------------------ // 4. Selection-persist highlights (visual-only, not contentEditable) // ------------------------------------------------------------ const HIGHLIGHT_TAG = 'mark'; const HIGHLIGHT_CLASS = 'editor-selection-persist'; export function wrapRangeInHighlight(range) { // Wrap the range contents in a <mark class="editor-selection-persist"> const wrapper = document.createElement(HIGHLIGHT_TAG); wrapper.className = HIGHLIGHT_CLASS; wrapper.dataset.selectionPersist = 'true'; wrapper.appendChild(range.extractContents()); range.insertNode(wrapper); wrapper.parentNode?.normalize(); return wrapper; } export function unwrapSelectionHighlights(root) { if (!root) return; root.querySelectorAll(`${HIGHLIGHT_TAG}.${HIGHLIGHT_CLASS}`).forEach(el => { const parent = el.parentNode; if (!parent) return; const children = Array.from(el.childNodes); if (children.length) { el.replaceWith(...children); } else { el.remove(); } parent.normalize(); }); } export function isInsideAutotypeAnchor(node) { let el = node.nodeType === Node.TEXT_NODE ? node.parentElement : node; while (el) { if (el.classList?.contains('autotype-anchor')) return true; el = el.parentElement; } return false; } function getAutotypeAnchor(node) { let el = node.nodeType === Node.TEXT_NODE ? node.parentElement : node; while (el) { if (el.classList?.contains('autotype-anchor')) return el; el = el.parentElement; } return null; } export function unwrapAutotypeAnchor(anchor) { const sel = window.getSelection(); let savedStartFlat = null; let savedEndFlat = null; let savedScope = null; if (sel.rangeCount) { const range = sel.getRangeAt(0); if (anchor.contains(range.commonAncestorContainer)) { savedScope = anchor.parentElement; if (savedScope) { savedStartFlat = getFlatOffset(range.startContainer, range.startOffset, savedScope); savedEndFlat = getFlatOffset(range.endContainer, range.endOffset, savedScope); } } } const parent = anchor.parentNode; if (!parent) return []; const suggestionIds = []; if (anchor.dataset.autotypeSuggestions) { try { const list = JSON.parse(anchor.dataset.autotypeSuggestions); if (Array.isArray(list)) { suggestionIds.push(...list.map(s => s.id)); } } catch (e) { // ignore malformed JSON } } const children = Array.from(anchor.childNodes); if (children.length) { anchor.replaceWith(...children); } else { anchor.remove(); } parent?.normalize(); if (savedScope && savedStartFlat !== null && savedEndFlat !== null) { const startInfo = findNodeAndOffsetAtChar(savedScope, savedStartFlat); const endInfo = findNodeAndOffsetAtChar(savedScope, savedEndFlat); if (startInfo && endInfo) { const newRange = document.createRange(); newRange.setStart(startInfo.node, startInfo.offset); newRange.setEnd(endInfo.node, endInfo.offset); sel.removeAllRanges(); sel.addRange(newRange); } } return suggestionIds; } export function getSentenceAtCaret(range, root) { const block = getParagraphElement(range.startContainer, root); if (!block) return null; const full = block.textContent; const caret = getFlatOffset(range.startContainer, range.startOffset, block); let s = caret; while (s > 0 && !'.!?'.includes(full[s - 1])) s--; while (s < caret && /\s/.test(full[s])) s++; let e = caret; while (e < full.length && !'.!?'.includes(full[e])) e++; if (e < full.length && '.!?'.includes(full[e])) e++; const text = full.slice(s, e).trim(); if (!text) return null; const startInfo = findNodeAndOffsetAtChar(block, s); const endInfo = findNodeAndOffsetAtChar(block, e); if (!startInfo || !endInfo) return null; const r = document.createRange(); r.setStart(startInfo.node, startInfo.offset); r.setEnd(endInfo.node, endInfo.offset); return { range: r, text, container: block }; } export function getLastCompletedSentence(range, root, isEnter = false) { const currentBlock = getParagraphElement(range.startContainer, root); if (!currentBlock) return null; function extractSentence(block, startChar, endChar) { const text = block.textContent.slice(startChar, endChar).trim(); if (!text) return null; const startInfo = findNodeAndOffsetAtChar(block, startChar); const endInfo = findNodeAndOffsetAtChar(block, endChar); if (!startInfo || !endInfo) return null; const r = document.createRange(); r.setStart(startInfo.node, startInfo.offset); r.setEnd(endInfo.node, endInfo.offset); return { range: r, text, container: block }; } function findSentenceInBlock(block, fallbackToWhole = false) { const text = block.textContent; let end = text.length; while (end > 0 && /\s/.test(text[end - 1])) end--; let terminatorPos = -1; for (let i = end - 1; i >= 0; i--) { if ('.!?'.includes(text[i])) { terminatorPos = i; break; } } if (terminatorPos !== -1) { // find the start of this sentence (after the previous terminator) let start = terminatorPos - 1; while (start >= 0 && !'.!?'.includes(text[start])) start--; start++; // move past the previous terminator (or to 0 if at beginning) while (start < terminatorPos && /\s/.test(text[start])) start++; return extractSentence(block, start, terminatorPos + 1); } if (fallbackToWhole) { let start = 0; while (start < end && /\s/.test(text[start])) start++; return extractSentence(block, start, end); } return null; } if (isEnter) { const prevBlock = currentBlock?.previousElementSibling; if (prevBlock && prevBlock !== root) { const res = findSentenceInBlock(prevBlock, true); if (res) return res; } return findSentenceInBlock(currentBlock, true); } return findSentenceInBlock(currentBlock, false); } // ------------------------------------------------------------ // 5. Context helpers (simplified) // ------------------------------------------------------------ function findSentenceBounds(full, caret) { let s = caret; while (s > 0 && !'.!?'.includes(full[s - 1])) s--; // Skip whitespace after the preceding terminator so we don't eat the space while (s < caret && /\s/.test(full[s])) 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 }; } function getTextWithBlockBreaks(element) { let text = ''; const walker = document.createTreeWalker(element, NodeFilter.SHOW_TEXT); let prevBlock = null; let n; while ((n = walker.nextNode())) { const block = getBlockParent(n, element); if (prevBlock && block !== prevBlock) { text += '\n'; } text += n.textContent; prevBlock = block; } return text; } export function getContextInfo(range, root) { const containerElement = getParagraphElement(range.commonAncestorContainer, root); if (!containerElement) return null; const full = getTextWithBlockBreaks(containerElement); 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; let startInfo = findNodeAndOffsetAtChar(containerElement, _docStart); let endInfo = findNodeAndOffsetAtChar(containerElement, _docEnd); if (!startInfo || !endInfo) return []; let r = document.createRange(); r.setStart(startInfo.node, startInfo.offset); r.setEnd(endInfo.node, endInfo.offset); let idsToRemove = []; const anchor = getAutotypeAnchor(r.commonAncestorContainer); if (anchor && containerElement.contains(anchor)) { idsToRemove.push(...unwrapAutotypeAnchor(anchor)); startInfo = findNodeAndOffsetAtChar(containerElement, _docStart); endInfo = findNodeAndOffsetAtChar(containerElement, _docEnd); if (!startInfo || !endInfo) return idsToRemove; 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); containerElement.normalize(); return idsToRemove; } /* ============================================================ ddlm-api – shared LiteLLM streaming utilities ============================================================ */ window.litellm_key = "sk-Fc9yPVHq_G9_IL6fMw39MQ"; export const ENDPOINT = 'https://litellm.stream.cavi.au.dk/v1/chat/completions'; export 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'); } export 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 } } } } } 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('debug/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('debug/hl-para', 'Toggle paragraph highlight', handler); return null; } import React, { useCallback } from 'react'; import { ensureApiKey, streamCompletion } from '#App [name="dllm-utils"]'; /* ============================================================ DLLMReimaginePlugin – LLaDA-powered text reimagining ============================================================ */ async function runReimagine(apiKey, settings, assistantContent) { const model = window.activeModel?.name || 'haic/llada2.1-mini-pruned128'; const systemPrompt = 'Only requested text based on instructions in output - no comments or prefixing'; const userPrompt = settings.plugin?.prompt || ''; const messages = [ { role: 'system', content: systemPrompt }, { role: 'user', content: userPrompt?userPrompt:"random interesting text, the text only, no chat, no talk, no preface" }, { role: 'assistant', content: assistantContent }, ]; const body = { model, messages, stream: true, extra_body: { temperature: settings.model?.temp ?? 0.7, steps: settings.model?.steps ?? 32, remasking: settings.model?.remasking ?? 'low_confidence', block_size: settings.model?.blocksize ?? 24, seed: settings.model?.seed ?? 42, includeSteps: true, } }; let content = ''; for await (const chunk of streamCompletion(apiKey, body)) { content += chunk; } return content; } const MAX_SELECTION_TOKENS = 96; function estimateSelectionTokens(text, selectionStart, selectionEnd) { const selected = text.slice(selectionStart, selectionEnd); const words = selected.trim().split(/\s+/).filter((w) => w.length > 0).length; const symbols = (selected.match(/[^a-zA-Z0-9\s]/g) || []).length; return words + symbols; } /* ------------------------------------------------------------ Word-bounding helpers for "around selection" ------------------------------------------------------------ */ function findWordBoundsBackward(text, fromIndex, wordCount) { if (wordCount <= 0) return { start: fromIndex, end: fromIndex }; let end = fromIndex; while (end > 0 && /\s/.test(text[end - 1])) end--; let start = end; let wordsFound = 0; while (start > 0 && wordsFound < wordCount) { while (start > 0 && /\S/.test(text[start - 1])) start--; wordsFound++; if (wordsFound >= wordCount) break; while (start > 0 && /\s/.test(text[start - 1])) start--; } return { start, end }; } function findWordBoundsForward(text, fromIndex, wordCount) { if (wordCount <= 0) return { start: fromIndex, end: fromIndex }; let start = fromIndex; while (start < text.length && /\s/.test(text[start])) start++; let end = start; let wordsFound = 0; while (end < text.length && wordsFound < wordCount) { while (end < text.length && /\S/.test(text[end])) end++; wordsFound++; if (wordsFound >= wordCount) break; while (end < text.length && /\s/.test(text[end])) end++; } return { start, end }; } export function DLLMReimaginePlugin({ useEditor, useContextMenuItem }) { const { openPromptModal } = useEditor(); const executePrompt = useCallback(async (settings, context) => { const apiKey = ensureApiKey(); const { text, selectionStart, selectionEnd } = context; const selectedPart = text.slice(selectionStart, selectionEnd); const wordCount = selectedPart.split(' ').filter((w) => w.length > 0).length; const dots = '…'.repeat(Math.max(1, wordCount)); const before = text.slice(0, selectionStart); const after = text.slice(selectionEnd); const assistantContent = before + dots + after; return runReimagine(apiKey, settings, assistantContent); }, []); const executeAroundPrompt = useCallback(async (settings, context) => { const apiKey = ensureApiKey(); const { text, selectionStart, selectionEnd } = context; const plugin = settings.plugin || {}; const beforeWords = Math.max(0, Math.min(plugin.before ?? 5, 25)); const afterWords = Math.max(0, Math.min(plugin.after ?? 5, 25)); const availableBefore = (text.slice(0, selectionStart).match(/\S+/g) || []).length; const actualBefore = Math.min(beforeWords, availableBefore); const availableAfter = (text.slice(selectionEnd).match(/\S+/g) || []).length; const actualAfter = Math.min(afterWords, availableAfter); const beforeBounds = findWordBoundsBackward(text, selectionStart, actualBefore); const afterBounds = findWordBoundsForward(text, selectionEnd, actualAfter); const prefix = text.slice(0, beforeBounds.start); const beforeGap = text.slice(beforeBounds.end, selectionStart); const selectionText = text.slice(selectionStart, selectionEnd); const afterGap = text.slice(selectionEnd, afterBounds.start); const suffix = text.slice(afterBounds.end); const beforeDots = '…'.repeat(Math.max(0, actualBefore)); const afterDots = '…'.repeat(Math.max(0, actualAfter)); const assistantContent = prefix + beforeDots + beforeGap + selectionText + afterGap + afterDots + suffix; return runReimagine(apiKey, settings, assistantContent); }, []); const executeExpandPrompt = useCallback(async (settings, context) => { const apiKey = ensureApiKey(); const { text, selectionStart, selectionEnd } = context; const plugin = settings.plugin || {}; const before = Math.max(0, Math.min(plugin.before ?? 5, 25)); const after = Math.max(0, Math.min(plugin.after ?? 5, 25)); const beforeDots = '…'.repeat(before); const afterDots = '…'.repeat(after); const prefix = text.slice(0, selectionStart); const selectionText = text.slice(selectionStart, selectionEnd); const suffix = text.slice(selectionEnd); const assistantContent = prefix + beforeDots + selectionText + afterDots + suffix; return runReimagine(apiKey, settings, assistantContent); }, []); const handler = useCallback( (ctx) => { const pluginParams = { prompt: { name: 'Prompt', type: 'text', default: '', hint: 'Optional instructions for the reimagining', } }; openPromptModal({ ...ctx, pluginParams }, executePrompt); }, [openPromptModal, executePrompt] ); const aroundHandler = useCallback( (ctx) => { const pluginParams = { before: { name: 'Before', type: 'range', default: 5, min: 0, max: 25, hint: 'Words before selection to reimagine', }, after: { name: 'After', type: 'range', default: 5, min: 0, max: 25, hint: 'Words after selection to reimagine', }, prompt: { name: 'Prompt', type: 'text', default: '', hint: 'Optional instructions for the reimagining', } }; const inputHighlights = (settings, _context) => { const { text, selectionStart, selectionEnd } = _context; const plugin = settings.plugin || {}; const beforeWords = Math.max(0, Math.min(plugin.before ?? 5, 25)); const afterWords = Math.max(0, Math.min(plugin.after ?? 5, 25)); const availableBefore = (text.slice(0, selectionStart).match(/\S+/g) || []).length; const actualBefore = Math.min(beforeWords, availableBefore); const availableAfter = (text.slice(selectionEnd).match(/\S+/g) || []).length; const actualAfter = Math.min(afterWords, availableAfter); const beforeBounds = findWordBoundsBackward(text, selectionStart, actualBefore); const afterBounds = findWordBoundsForward(text, selectionEnd, actualAfter); const ranges = []; if (beforeBounds.start < beforeBounds.end) ranges.push({ start: beforeBounds.start, end: beforeBounds.end }); if (afterBounds.start < afterBounds.end) ranges.push({ start: afterBounds.start, end: afterBounds.end }); return ranges; }; openPromptModal({ ...ctx, pluginParams, inputHighlights }, executeAroundPrompt); }, [openPromptModal, executeAroundPrompt] ); const expandHandler = useCallback( (ctx) => { const pluginParams = { before: { name: 'Before', type: 'range', default: 5, min: 0, max: 25, hint: 'Number of placeholder chars before selection', }, after: { name: 'After', type: 'range', default: 5, min: 0, max: 25, hint: 'Number of placeholder chars after selection', }, prompt: { name: 'Prompt', type: 'text', default: '', hint: 'Optional instructions for the expansion', } }; openPromptModal({ ...ctx, pluginParams }, executeExpandPrompt); }, [openPromptModal, executeExpandPrompt] ); const selectionTooBigTitle = `Selection is too large (>${MAX_SELECTION_TOKENS} tokens). Select less text.`; useContextMenuItem( 'llm/reimagine', 'Selection itself…', handler, (ctx) => ctx.selectionEnd > ctx.selectionStart, "Re-imagine…", (ctx) => estimateSelectionTokens(ctx.text, ctx.selectionStart, ctx.selectionEnd) > MAX_SELECTION_TOKENS, selectionTooBigTitle ); useContextMenuItem( 'llm/reimagine-around', 'Around selection…', aroundHandler, (ctx) => ctx.selectionEnd > ctx.selectionStart, "Re-Imagine…", (ctx) => estimateSelectionTokens(ctx.text, ctx.selectionStart, ctx.selectionEnd) > MAX_SELECTION_TOKENS, selectionTooBigTitle ); useContextMenuItem( 'expand', 'Expand…', expandHandler, (ctx) => ctx.selectionEnd > ctx.selectionStart, undefined, (ctx) => estimateSelectionTokens(ctx.text, ctx.selectionStart, ctx.selectionEnd) > MAX_SELECTION_TOKENS, selectionTooBigTitle ); return null; } export function AutotypeDebugPlugin({ useEditor, useAutotypePlugin }) { const { addSuggestion } = useEditor(); useAutotypePlugin('autotype/debug', async ({ sentenceText, anchorId }) => { console.log('=== AUTOTYPE DEBUG CONTEXT ==='); console.log('Sentence text :', sentenceText); console.log('Anchor ID :', anchorId); console.log('Callback invoke: addSuggestion({ id, anchorId, originalText, suggestedText })'); console.log('================================'); }, null, 'Debug Logger'); return null; } /* ------------------------------------------------------------ Lowercase plugin – suggests lowercasing sentences with upper-case words ------------------------------------------------------------ */ export function AutotypeLowercasePlugin({ useEditor, useAutotypePlugin }) { const { addSuggestion } = useEditor(); useAutotypePlugin('autotype/lowercase', async ({ sentenceText, anchorId }) => { if (!sentenceText?.trim()) return; // Check if sentence contains any word with an upper-case letter const hasUppercaseWord = /\b\w*[A-Z]\w*\b/.test(sentenceText); if (!hasUppercaseWord) return; const suggested = sentenceText.toLowerCase(); if (suggested === sentenceText) return; addSuggestion({ id: `sugg-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`, anchorId, originalText: sentenceText, suggestedText: suggested, }); }, null, 'Lowercase Suggester'); return null; } import React from 'react'; import { ensureApiKey, streamCompletion } from '#App [name="dllm-utils"]'; /* ============================================================ AutotypeLLMPlugin – LLM-powered sentence rewriting suggestions ============================================================ */ const PARAMS = { chance: { type: 'range', min: 1, max: 100, step: 1, default: 10, hint: 'Chance to run when typing sentences (1–100%). Manual trigger (Ctrl+Space) always runs.', }, temp: { type: 'range', min: 0, max: 2, step: 0.1, default: 0.7, hint: 'Sampling temperature', }, max_tokens: { type: 'range', min: 1, max: 512, step: 1, default: 64, hint: 'Maximum tokens to generate', }, top_p: { type: 'range', min: 0, max: 1, step: 0.05, default: 0.95, hint: 'Nucleus sampling parameter', }, prompt: { type: 'text', rows: 3, default: 'Rewrite this sentence to be more clear and concise.', hint: 'Instruction sent to the LLM alongside the current sentence', }, }; export function AutotypeLLMPlugin({ useEditor, useAutotypePlugin }) { const { addSuggestion, getAutotypeSettings } = useEditor(); useAutotypePlugin('autotype/llm', async ({ sentenceText, anchorId, trigger }) => { if (!sentenceText?.trim()) return; const modelName = 'cavi/small'; const settings = getAutotypeSettings('autotype/llm'); // Only apply chance for auto trigger; manual trigger (Ctrl+Space) always runs if (trigger === 'auto') { const chance = settings.chance ?? PARAMS.chance.default; const roll = Math.random() * 100; if (roll > chance) return; } const userPrompt = settings.prompt ?? PARAMS.prompt.default; const temp = settings.temp ?? PARAMS.temp.default; const max_tokens = settings.max_tokens ?? PARAMS.max_tokens.default; const top_p = settings.top_p ?? PARAMS.top_p.default; const apiKey = ensureApiKey(); const messages = [ { role: 'system', content: 'You are a helpful writing assistant. Only output the rewritten text, with no extra commentary, quotes, or explanation.' }, { role: 'user', content: `${userPrompt}\n\n"${sentenceText}"` }, ]; const body = { model: modelName, messages, stream: true, temperature: temp, max_tokens, top_p, }; try { let content = ''; for await (const chunk of streamCompletion(apiKey, body)) { content += chunk; } content = content.trim(); if (!content || content === sentenceText) return; addSuggestion({ id: `sugg-at-llm-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`, anchorId, originalText: sentenceText, suggestedText: content, }); } catch (err) { console.error('Autotype LLM error:', err); } }, PARAMS, 'AI Auto-typist'); return null; } html, body { height: 100%; } body { background: grey; padding: 0; margin: 0; } .app-layout { display: flex; height: 100vh; overflow: hidden; } /* 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); } .prototype-overlay { position: fixed; bottom: 32px; left: -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; } }