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;
}
}