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, getAutotypeAnchor } from '#App [name="text-utils"]';
const EditorCtx = createContext(null);
/* Record a suggestion on its anchor span (dataset powers rehydration
after reloads). Deduplicates by suggestion id. */
function appendSuggestionToAnchor(anchor, suggestion) {
if (!anchor || !suggestion) return;
let list = [];
if (anchor.dataset.autotypeSuggestions) {
try {
list = JSON.parse(anchor.dataset.autotypeSuggestions);
if (!Array.isArray(list)) list = [];
} catch (e) {
list = [];
}
}
if (!list.some((s) => s.id === suggestion.id)) {
list.push(suggestion);
anchor.dataset.autotypeSuggestions = JSON.stringify(list);
}
}
/* Best-effort caret placement after an anchor was replaced by a fresh
text node. normalize() may merge that node into a preceding sibling
and detach it, so aiming at the node itself can throw
InvalidNodeTypeError — aim at the end of the absorbing sibling
instead, and never let caret trouble block the caller. */
function restoreCaretAfterReplacement(textNode, prevSibling) {
try {
const sel = window.getSelection();
const caretRange = document.createRange();
let placed = false;
if (textNode.parentNode) {
caretRange.setStartAfter(textNode);
placed = true;
} else if (prevSibling && prevSibling.nodeType === Node.TEXT_NODE && prevSibling.parentNode) {
// normalize() merged the replacement into the preceding text
// node, which now ends with the replacement text.
caretRange.setStart(prevSibling, prevSibling.textContent.length);
placed = true;
}
if (placed) {
caretRange.collapse(true);
sel.removeAllRanges();
sel.addRange(caretRange);
}
} catch (e) {
// Caret restoration is cosmetic here; swallow so that the popup
// cleanup in the caller always runs.
}
}
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);
// Anchor already exists (several suggestions for one trigger): just
// record the suggestion on it and keep its id.
if (anchor) {
if (suggestion) appendSuggestionToAnchor(anchor, { ...suggestion, anchorId });
return anchorId;
}
const pending = pendingAutotypes.current.get(anchorId);
if (!pending) return null;
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 null;
}
actualStart = idx;
actualEnd = idx + sentenceTrimmed.length;
}
const startInfo = findNodeAndOffsetAtChar(paragraphElement, actualStart);
const endInfo = findNodeAndOffsetAtChar(paragraphElement, actualEnd);
if (!startInfo || !endInfo) {
pendingAutotypes.current.delete(anchorId);
return null;
}
// The sentence is already wrapped in an anchor — a duplicate dispatch
// raced past the editor's "inside an anchor" guard while the first
// request was still in flight. Reuse that anchor instead of nesting a
// second span around it: nested anchors leave a second suggestion
// popup that lingers after the first one is accepted/rejected.
const existingAnchor = getAutotypeAnchor(startInfo.node);
if (
existingAnchor &&
existingAnchor === getAutotypeAnchor(endInfo.node) &&
existingAnchor.dataset.autotypeOriginal === sentenceTrimmed
) {
if (suggestion) {
appendSuggestionToAnchor(existingAnchor, { ...suggestion, anchorId: existingAnchor.id });
}
pendingAutotypes.current.delete(anchorId);
return existingAnchor.id;
}
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) {
appendSuggestionToAnchor(span, { ...suggestion, anchorId });
}
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 anchorId;
}, []);
const dispatchSentenceCompletion = useCallback((sentenceText, anchorId, paragraphElement, startOffset, endOffset, trigger = 'auto') => {
// Supersede any still-pending completion for the very same sentence
// (same paragraph and character range). A sentence can be re-triggered
// while its first request is in flight — typing ". " followed by Enter
// completes the same sentence twice, and so does a Ctrl+Space retry.
// The stale response would otherwise wrap a second, nested anchor
// around the first, whose popup lingers after the other is accepted.
const text = (sentenceText || '').trim();
for (const [id, pending] of Array.from(pendingAutotypes.current)) {
if (
pending.paragraphElement === paragraphElement &&
(pending.sentenceText || '').trim() === text &&
pending.startOffset === startOffset &&
pending.endOffset === endOffset
) {
pendingAutotypes.current.delete(id);
}
}
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;
// ensureAnchor creates — or reuses — the anchor span and returns the id
// the suggestion is actually bound to (a duplicate dispatch may have
// been folded into an already existing anchor).
const anchorId = ensureAnchor(suggestion.anchorId, suggestion);
if (!anchorId) return;
const s = { ...suggestion, anchorId };
setSuggestions(prev => {
if (prev.some(p => p.id === s.id)) return prev;
return [...prev, s];
});
}, [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);
const prevSibling = anchor.previousSibling;
skipSelectionChange.current = true;
anchor.replaceWith(textNode);
textNode.parentNode?.normalize();
restoreCaretAfterReplacement(textNode, prevSibling);
setTimeout(() => { skipSelectionChange.current = false; }, 0);
// Remove every suggestion bound to this anchor (a retried sentence can
// stack several on one span) and always hide the popup.
setSuggestions(prev => prev.filter(su => su.anchorId !== s.anchorId));
setActiveSuggestionId(null);
}, [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);
const prevSibling = anchor.previousSibling;
skipSelectionChange.current = true;
anchor.replaceWith(textNode);
textNode.parentNode?.normalize();
restoreCaretAfterReplacement(textNode, prevSibling);
setTimeout(() => { skipSelectionChange.current = false; }, 0);
// Same as accept: drop all suggestions of this anchor and hide the popup.
setSuggestions(prev => prev.filter(su => su.anchorId !== s.anchorId));
setActiveSuggestionId(null);
}, [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 createMDLMParams() {
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',
},
cfg_scale: {
name: 'Guidance',
type: 'range',
min: 0,
max: 5,
step: 0.1,
default: 0,
hint: 'Guidance (cfg_scale): classifier-free guidance strength: blends the model\u2019s prompt-conditioned logits with an unconditioned pass. 0 = off; higher values sharpen adherence to the prompt at the cost of diversity. Needs un-training-free CFG; only supported by models run through MDLM sampling.',
},
stochastic_transfer: {
name: 'Re-roll tokens',
type: 'enum',
values: [
{ value: 'false', title: 'Off', hint: 'Frozen after first commit (default)' },
{ value: 'true', title: 'On', hint: 'Committed tokens can still change in later steps' },
],
default: 'false',
hint: 'Re-roll tokens (stochastic_transfer): whether already-committed tokens are re-sampled (stochastically transferred) in later denoising steps instead of being frozen at their first argmax commit. Off = more stable, cheaper; On = can fix earlier mistakes but output can keep wobbling.',
},
seed: {
type: 'range',
min: 0,
max: 999999,
step: 1,
default: 42,
hint: 'Random seed',
},
};
}
/* DiffusionGemma (block-diffusion, encoder-decoder; knobs accepted by the
dllm-fastapi server and forwarded into DiffusionGemmaSamplerConfig).
Ranges/hints follow the sampler code and the diffusiongemma examples. */
function createDiffusionGemmaParams() {
return {
steps: {
type: 'range',
min: 8,
max: 128,
step: 1,
default: 48,
hint: 'Max denoising steps per 256-token canvas block. Quality rises with steps, latency rises too. Adaptive stopping usually ends early, so this is a budget, not a fixed cost (evals use 48).',
},
entropy_bound: {
name: 'Write speed',
type: 'range',
min: 0,
max: 1,
step: 0.01,
default: 0.1,
hint: 'Write speed (entropy_bound): per-step token budget (in nats of entropy) deciding how many canvas tokens are accepted per denoising step: tokens are sorted by entropy (most confident first) and accepted until the cumulative entropy budget is spent. Higher = more tokens accepted per step = faster but noisier; lower = stricter, more careful. 0.1 for evals, down to ~0.001 for strict infill.',
},
entropy_threshold: {
name: 'Stop threshold',
type: 'range',
min: 0,
max: 0.5,
step: 0.001,
default: 0.005,
hint: 'Stop threshold (entropy_threshold): early-stop confidence threshold: mean token entropy (in nats) below this marks the canvas as converged and stops denoising early. Lower = later stop, more refinement; higher = stop sooner, faster.',
},
stability_threshold: {
name: 'Stop patience',
type: 'range',
min: 0,
max: 10,
step: 1,
default: 1,
hint: 'Stop patience (stability_threshold): number of consecutive steps the most-confident (argmax) canvas must stay unchanged (AND below entropy_threshold) before early stopping. 0 = stability check disabled (only entropy threshold applies), 1 = default.',
},
pin_anchor_alpha: {
name: 'Anchor strength',
type: 'range',
min: 0,
max: 1,
step: 0.05,
default: 1,
hint: 'Anchor strength (pin_anchor_alpha): infill only. 1.0 = hard pins: your text always survives verbatim and the model plans the whole gap around it. Below 1.0 = soft pins: your text stays visible to the model through every step, but the model may rewrite it at the end if it confidently disagrees — e.g. fix a pinned "She" to "He" once "his head" has been diffused around it. Lower values = more model trust. Plain generation ignores this.',
},
max_tokens: {
name: 'Max tokens',
type: 'range',
min: 256,
max: 1024,
step: 256,
default: 256,
hint: 'Max tokens (max_tokens): generation budget in tokens, rounded up to full 256-token canvas blocks (256 = one block, 512 = two, ...). The model stops at the end-of-turn token, so this is a cap, not a target. Infill only pads this to the suggestion span (the turn boundary is pinned there anyway).',
},
max_temperature: {
name: 'Temp start',
type: 'range',
min: 0.05,
max: 2,
step: 0.05,
default: 0.8,
hint: 'Temp start (max_temperature): sampling temperature at the first denoising step. DiffusionGemma anneals linearly from this value down to Temp end across the steps: higher start = more exploration/diversity early on; set start = end for a constant temperature. Near 0 ≈ greedy.',
},
min_temperature: {
name: 'Temp end',
type: 'range',
min: 0.05,
max: 2,
step: 0.05,
default: 0.4,
hint: 'Temp end (min_temperature): sampling temperature at the last denoising step. DiffusionGemma anneals linearly from Temp start down to this value: lower end = sharper, more deterministic convergence. Near 0 ≈ greedy.',
},
seed: {
type: 'range',
min: 0,
max: 999999,
step: 1,
default: 42,
hint: 'Random seed',
},
};
}
window.availableModels = [
{ name: "haic/llada2.1-mini-pruned128", params: createMDLMParams() },
{ name: "haic/qwen3-0.6b-diffusion-mdlm-v0.1", params: createMDLMParams() },
{ name: "nugget/diffusiongemma-26B-A4B-it", params: createDiffusionGemmaParams() },
];
/* Per-model request bodies are built by buildModelRequestBody() in
#App [name="dllm-utils"] (import it where needed). */
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');
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) => {
if (e.inputType !== 'insertText' && e.inputType !== 'insertParagraph' && e.inputType !== 'insertLineBreak') {
return;
}
const sel = window.getSelection();
if (!sel.rangeCount) {
return;
}
const range = sel.getRangeAt(0);
let sentence = null;
if (e.inputType === 'insertParagraph' || e.inputType === 'insertLineBreak') {
sentence = utils.getLastCompletedSentence(range, el, true);
} else if (e.inputType === 'insertText' && e.data === ' ') {
const block = utils.getParagraphElement(range.endContainer, el);
if (!block) {
return;
}
const caretFlat = utils.getFlatOffset(range.endContainer, range.endOffset, block);
const trimmedLen = block.textContent.trimEnd().length;
if (caretFlat < trimmedLen) {
return;
}
if (caretFlat >= 2) {
const precedingChar = block.textContent[caretFlat - 2];
if ('.!?'.includes(precedingChar)) {
sentence = utils.getLastCompletedSentence(range, el, false);
}
}
}
if (!sentence) {
return;
}
if (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);
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,
// Kept so the prompt modal can re-derive the context at a different
// scope (sentence / sibling sentences / paragraph / …) on the fly.
_sourceRange: range.cloneRange(),
_root: root,
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"]';
import { getContextInfo, replaceContextRange } from '#App [name="text-utils"]';
/* ------------------------------------------------------------
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' || type === 'context-scope') {
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, removeSuggestion } = 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]
);
// A plugin param of type 'context-scope' lets the user choose how far
// around the selection the context extends (sentence, sibling sentences,
// paragraph, sibling paragraphs, document). When one is present, re-derive
// the text context from the original selection at the chosen scope; the
// plugin then transparently operates on that larger context.
const scopeParamKey = Object.keys(pluginParams).find(
(k) => pluginParams[k] && pluginParams[k].type === 'context-scope'
) || null;
const scopeValue = scopeParamKey ? effectivePluginSettings[scopeParamKey] : null;
const scopedContext = React.useMemo(() => {
if (!scopeValue || !modalContext._sourceRange || !modalContext._root) return modalContext;
try {
const info = getContextInfo(modalContext._sourceRange, modalContext._root, scopeValue);
if (!info) return modalContext;
return {
...modalContext,
text: info.text,
selectionStart: info.selectionStart,
selectionEnd: info.selectionEnd,
replace: (newText) => {
const idsToRemove = replaceContextRange(info, newText);
idsToRemove.forEach((id) => removeSuggestion(id));
},
};
} catch (e) {
return modalContext;
}
}, [modalContext, scopeValue, removeSuggestion]);
// 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, scopedContext))
.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, scopedContext]);
// 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 } = scopedContext;
const buildInputHtml = () => {
if (!text) return '';
let extraRanges = [];
if (typeof modalContext.inputHighlights === 'function') {
try {
extraRanges = modalContext.inputHighlights({ plugin: effectivePluginSettings }, scopedContext) || [];
} 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);
// 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);
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 };
}
}
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}`;
}
/* Initial param values from an explicit (already-prefixed) storage key. */
export function getInitialValuesFromKey(params, fullStorageKey) {
try {
const saved = JSON.parse(localStorage.getItem(fullStorageKey) || '{}');
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])
);
}
}
export function getInitialValues(params, modelKey = 'activeModel') {
return getInitialValuesFromKey(params, getStorageKey(modelKey));
}
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(() => getInitialValuesFromKey(params, storageKey));
useEffect(() => {
setValues(getInitialValuesFromKey(params, storageKey));
}, [params, storageKey]);
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)
.filter(([, spec]) => !spec.showIf || spec.showIf(values))
.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}`}>
{spec.name || 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)}
/>
)}
{spec.type === 'checkbox' && (
<label className="setup-panel__toggle" htmlFor={`${storageKey}-${key}`}>
<input
id={`${storageKey}-${key}`}
type="checkbox"
checked={!!values[key]}
onChange={e => updateValue(key, e.target.checked)}
/>
</label>
)}
</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;
}
export 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 (scope-aware)
// ------------------------------------------------------------
// A context scope decides how far around the selection the text context
// (what the model sees, and what Accept replaces) extends:
// sentence – the sentence(s) containing the selection
// sibling-sentences – adds the previous and the next sentence
// paragraph – every sentence of the paragraph(s) containing the selection
// sibling-paragraphs – adds the previous and the next paragraph
// document – the entire document
export const CONTEXT_SCOPES = [
'sentence',
'sibling-sentences',
'paragraph',
'sibling-paragraphs',
'document',
];
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;
}
/* Sentence spans [{start, end}] within one block's text. A run of
terminators ("...", "!?") stays attached to the sentence it ends, and
leading whitespace between sentences belongs to no span. */
function splitSentenceSpans(text) {
const spans = [];
const n = text.length;
let i = 0;
while (i < n) {
while (i < n && /\s/.test(text[i])) i++;
if (i >= n) break;
let e = i;
while (e < n && !'.!?'.includes(text[e])) e++;
if (e < n) {
while (e + 1 < n && '.!?'.includes(text[e + 1])) e++;
e++;
}
spans.push({ start: i, end: e });
i = e;
}
return spans;
}
/* Block groups: maximal runs of text nodes sharing the same closest block
element, as flat [start, end) ranges in getTextWithBlockBreaks(root)
coordinates (the '\n' between blocks belongs to no group). Mirrors the
transition rule of getTextWithBlockBreaks/getFlatOffset exactly, so the
flat offsets stay consistent across all three. */
function getBlockGroups(root) {
const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT);
const groups = [];
let flat = 0;
let prevBlock = null;
let cur = null;
let n;
while ((n = walker.nextNode())) {
const block = getBlockParent(n, root);
if (prevBlock && block !== prevBlock) {
if (cur) {
cur.end = flat;
groups.push(cur);
}
flat += 1; // the '\n' between blocks
cur = null;
}
if (!cur) cur = { element: block || root, start: flat };
flat += n.textContent.length;
cur.end = flat;
prevBlock = block;
}
if (cur) groups.push(cur);
return groups;
}
export function getContextInfo(range, root, scope = 'sentence') {
const full = getTextWithBlockBreaks(root);
const selStart = getFlatOffset(range.startContainer, range.startOffset, root);
const selEnd = getFlatOffset(range.endContainer, range.endOffset, root);
const groups = getBlockGroups(root);
if (!groups.length || selEnd < selStart) return null;
// Index of the last group that starts at or before `pos` (positions on a
// block boundary belong to the preceding group).
const groupIndexAt = (pos) => {
let k = 0;
for (let i = 0; i < groups.length; i++) {
if (groups[i].start <= pos) k = i;
else break;
}
return k;
};
const gi1 = groupIndexAt(selStart);
const gi2 = groupIndexAt(selEnd);
let contextStart = null;
let contextEnd = null;
if (scope === 'document') {
contextStart = 0;
contextEnd = full.length;
} else if (scope === 'paragraph' || scope === 'sibling-paragraphs') {
let a = gi1;
let b = gi2;
if (scope === 'sibling-paragraphs') {
if (gi1 > 0) a = gi1 - 1;
if (gi2 < groups.length - 1) b = gi2 + 1;
}
contextStart = groups[a].start;
contextEnd = groups[b].end;
} else {
// 'sentence' and 'sibling-sentences' live inside the block groups
const spansCache = new Map();
const groupSpans = (gi) => {
if (!spansCache.has(gi)) {
const g = groups[gi];
spansCache.set(gi, splitSentenceSpans(full.slice(g.start, g.end)));
}
return spansCache.get(gi);
};
// Index of the sentence span containing flat position `pos` within group
// `gi`. A position in the whitespace between sentences belongs to the
// NEXT sentence (matching the previous sentence-bound behaviour).
const spanIndexAt = (gi, pos) => {
const spans = groupSpans(gi);
if (!spans.length) return 0;
const p = pos - groups[gi].start;
let k = 0;
for (let i = 0; i < spans.length; i++) {
if (spans[i].start <= p) k = i;
else break;
}
if (p >= spans[k].end && k + 1 < spans.length) return k + 1;
return k;
};
const sentStart = (gi, i) => groups[gi].start + groupSpans(gi)[i].start;
const sentEnd = (gi, i) => groups[gi].start + groupSpans(gi)[i].end;
const i1 = spanIndexAt(gi1, selStart);
const i2 = spanIndexAt(gi2, selEnd);
contextStart = sentStart(gi1, i1);
contextEnd = sentEnd(gi2, i2);
if (scope === 'sibling-sentences') {
// One sentence further back — within the group, or (at a group edge)
// the last sentence of the previous group.
if (i1 > 0) {
contextStart = sentStart(gi1, i1 - 1);
} else if (gi1 > 0) {
const prevSpans = groupSpans(gi1 - 1);
if (prevSpans.length) contextStart = sentStart(gi1 - 1, prevSpans.length - 1);
}
// One sentence further forward — within the group, or the first
// sentence of the next group.
if (i2 < groupSpans(gi2).length - 1) {
contextEnd = sentEnd(gi2, i2 + 1);
} else if (gi2 < groups.length - 1) {
const nextSpans = groupSpans(gi2 + 1);
if (nextSpans.length) contextEnd = sentEnd(gi2 + 1, 0);
}
}
}
if (contextStart === null || contextEnd === null || contextEnd <= contextStart) return null;
// Clamp the selection into the context (it should always be inside, but
// never hand the model offsets outside the context text).
const s = Math.max(contextStart, Math.min(selStart, contextEnd));
const e = Math.max(contextStart, Math.min(selEnd, contextEnd));
// Block elements the context spans (used to clean up empty shells left
// behind when replacing across multiple paragraphs).
const coveredBlocks = groups
.filter((g) => g.end > contextStart && g.start < contextEnd)
.map((g) => g.element);
return {
text: full.slice(contextStart, contextEnd),
selectionStart: s - contextStart,
selectionEnd: e - contextStart,
containerElement: root,
_docStart: contextStart,
_docEnd: contextEnd,
_coveredBlocks: coveredBlocks,
_scope: scope,
};
}
export function replaceContextRange(ctxInfo, newText) {
const { containerElement, _docStart, _docEnd, _coveredBlocks } = 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();
// Contexts spanning several paragraphs (paragraph/sibling-paragraphs/
// document scopes) leave the partially-covered block elements behind as
// empty shells — the new text lands in the first one. Drop the blocks
// that are now completely empty.
if (_coveredBlocks && _coveredBlocks.length > 1) {
for (const block of _coveredBlocks) {
if (!block || !block.isConnected || block === containerElement) continue;
if (block.querySelector('img,video,audio,canvas,svg,iframe,br,hr,input,textarea')) continue;
if ((block.textContent || '').trim() === '') block.remove();
}
containerElement.normalize();
}
return idsToRemove;
}
/* ============================================================
ddlm-api – shared LiteLLM streaming utilities
============================================================ */
export const ENDPOINT = 'https://litellm.stream.cavi.au.dk/v1/chat/completions';
export function isDiffusionGemmaModel(modelName) {
return /diffusiongemma/i.test(modelName || '');
}
/**
* Build the model-specific sampler knobs for a /v1/chat/completions request
* (sent via `extra_body` through the LiteLLM proxy to the dllm-fastapi
* server). Keys map 1:1 to ChatReq fields on the server; each model kind
* only consumes its own knobs (the server ignores the rest).
*
* - MDLM-style samplers (LLaDA / Dream / Qwen3-diffusion):
* temperature, steps, remasking, block_size, cfg_scale,
* stochastic_transfer, seed
* - DiffusionGemma (block diffusion, encoder-decoder):
* steps, entropy_bound, entropy_threshold, stability_threshold,
* pin_anchor_alpha (infill-only), max_tokens, max_temperature /
* min_temperature (annealed linearly across denoising steps), seed.
* prefix_thought_channel and pin_eos_tail are internal-only (always
* true) and not exposed in the UI: the first pins the empty
* thought-channel header, the second pins EOT+pad after the suggestion
* span so the model wraps up instead of filling the whole 256-token
* canvas block. No single global temperature — the annealing pair
* replaces it.
*/
export function buildModelRequestBody(settings, modelName) {
const model = modelName || window.activeModel?.name || '';
const s = settings || {};
if (isDiffusionGemmaModel(model)) {
return {
seed: s.seed ?? 42,
steps: s.steps ?? 48,
includeSteps: false,
entropy_bound: s.entropy_bound ?? 0.1,
entropy_threshold: s.entropy_threshold ?? 0.005,
stability_threshold: s.stability_threshold ?? 1,
pin_anchor_alpha: s.pin_anchor_alpha ?? 1,
max_tokens: s.max_tokens ?? 128,
max_temperature: s.max_temperature ?? 0.8,
min_temperature: s.min_temperature ?? 0.4,
prefix_thought_channel: true,
pin_eos_tail: true,
};
}
// MDLM-style samplers (LLaDA / Dream / Qwen3-diffusion)
return {
seed: s.seed ?? 42,
steps: s.steps ?? 32,
includeSteps: false,
temperature: s.temp ?? 0.7,
remasking: s.remasking ?? 'low_confidence',
block_size: s.blocksize ?? 24,
cfg_scale: s.cfg_scale ?? 0,
stochastic_transfer: (s.stochastic_transfer === true || s.stochastic_transfer === 'true'),
};
}
/* ============================================================
Masking-pattern engine – diffusion auto-typist
Masked words are replaced by '…' (the dllm server turns each '…'
into a mask token and refills it); which words get masked is
decided by the selected pattern below. The whitespace around
masked words stays verbatim: the models' tokenizers put a word's
leading space inside its word piece, so those spaces must survive
into the request text (see applyMask for why).
============================================================ */
/* Param-storage prefixes for the per-kind diffusion model knobs
(both kinds define steps / max_tokens / seed with different
defaults, so they are namespaced to avoid key collisions). */
export const MDLM_PARAMS_PREFIX = 'mdlm.';
export const DIFFUSIONGEMMA_PARAMS_PREFIX = 'gemma.';
/* Tokens per word: how many mask tokens each masked word becomes (the
per-word generation budget for the refill). Bounds are shared by the
params UI and the runtime clamp. */
export const MASK_TOKENS_PER_WORD = { min: 1, max: 8, default: 1 };
/* RiTa.js word classes for the 'rita' pattern. `tags` are Penn
Treebank tag prefixes matched against RiTa.pos(word). */
export const RITA_CLASSES = [
{ value: 'nouns', title: 'Nouns', tags: ['nn'] },
{ value: 'verbs', title: 'Verbs', tags: ['vb'] },
{ value: 'adjectives', title: 'Adjectives', tags: ['jj'] },
{ value: 'adverbs', title: 'Adverbs', tags: ['rb'] },
{ value: 'prepositions', title: 'Prepositions', tags: ['in'] },
{ value: 'pronouns', title: 'Pronouns', tags: ['prp', 'pp'] },
{ value: 'determiners', title: 'Determiners/articles', tags: ['dt'] },
{ value: 'conjunctions', title: 'Conjunctions', tags: ['cc'] },
{ value: 'numbers', title: 'Numbers', tags: ['cd'] },
{ value: 'modals', title: 'Modal verbs', tags: ['md'] },
{ value: 'interjections', title: 'Interjections', tags: ['uh'] },
];
let ritaPromise = null;
/* Lazy-load RiTa.js (POS tagger) from CDN, at most once.
The npm dist build is a UMD bundle: when the page defines any of the
module/exports/define globals (WPM/babel leave such globals behind), the
bundle registers itself as a CommonJS/AMD module and never sets
window.RiTa. Fetching the text and evaluating it inside a wrapper whose
parameters shadow those three identifiers forces the browser branch
(window.RiTa = …) without touching the page's globals. */
export function loadRiTa() {
if (window.RiTa) return Promise.resolve(window.RiTa);
if (!ritaPromise) {
ritaPromise = (async () => {
try {
const response = await fetch('https://cdn.jsdelivr.net/npm/rita@2/dist/rita.js');
if (!response.ok) throw new Error(`RiTa.js CDN responded ${response.status}`);
const src = await response.text();
// Indirect eval runs in global scope; the wrapper's parameters
// shadow module/exports/define for everything evaluated inside.
(0, eval)('(function (module, exports, define) {\n' + src + '\n}).call(window)');
if (window.RiTa) return window.RiTa;
throw new Error('RiTa.js loaded but window.RiTa is missing');
} catch (err) {
ritaPromise = null;
throw err;
}
})();
}
return ritaPromise;
}
/* Word tokens with character ranges: [{word, start, end}]. */
export function tokenizeWords(text) {
const tokens = [];
const re = /\S+/g;
let m;
while ((m = re.exec(text)) !== null) {
tokens.push({ word: m[0], start: m.index, end: m.index + m[0].length });
}
return tokens;
}
/* Replace each masked word with `tokensPerWord` '…'; everything else —
including the whitespace flanking masked words — stays verbatim.
The flanking spaces must survive: the models' tokenizers carry a
word's leading space inside the word piece ('▁the'), so deleting the
spaces pins spaceless pieces ('the') against the mask tokens — a
shape natural text only has inside hyphenated compounds, which the
model then completes with dashes ('follow-the-instructions-above').
Spacing is still re-decided per refill: each mask is filled with a
word piece, and that piece brings (or lacks) its own leading space. */
export function applyMask(text, tokens, maskedIndices, tokensPerWord = 1) {
const n = tokens.length;
if (!n) return text;
const perWord = Math.max(1, Math.floor(Number(tokensPerWord) || 1));
const isMasked = (i) => maskedIndices.has(i);
let out = '';
const seg = (start, end, mask, dots = 1) => {
if (start >= end) return;
out += mask ? '…'.repeat(dots) : text.slice(start, end);
};
seg(0, tokens[0].start, false);
for (let i = 0; i < n; i++) {
const t = tokens[i];
seg(t.start, t.end, isMasked(i), perWord);
const gapEnd = i + 1 < n ? tokens[i + 1].start : text.length;
seg(t.end, gapEnd, false);
}
return out;
}
/* 1-based Fibonacci positions (1, 2, 3, 5, 8, 13, 21, …) up to n. */
export function fibonacciIndexSet(n) {
const out = new Set();
let a = 1;
let b = 2;
while (a <= n) {
out.add(a);
const next = a + b;
a = b;
b = next;
}
return out;
}
/* 1-based prime positions (2, 3, 5, 7, 11, 13, …) up to n. */
export function primeIndexSet(n) {
const out = new Set();
for (let c = 2; c <= n; c++) {
let isPrime = true;
for (let d = 2; d * d <= c; d++) {
if (c % d === 0) {
isPrime = false;
break;
}
}
if (isPrime) out.add(c);
}
return out;
}
function clampNumber(value, min, max, fallback) {
const n = Number(value);
if (!Number.isFinite(n)) return fallback;
return Math.max(min, Math.min(max, n));
}
/*
* Compute the set of word indices (0-based, into `tokens`) masked by a
* single-shot pattern. The 'chunk' pattern (multi-round progression) is
* handled by runMaskedAutotype; anything unknown returns null.
*/
export async function computeMaskIndices(tokens, pattern, settings) {
const n = tokens.length;
const s = settings || {};
const masked = new Set();
switch (pattern) {
case 'random': {
const percent = clampNumber(s.mask_random_percent, 5, 50, 25);
const maxCount = Math.max(1, Math.round((n * percent) / 100));
const count = 1 + Math.floor(Math.random() * maxCount);
const indices = [];
for (let i = 0; i < n; i++) indices.push(i);
for (let i = indices.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
const tmp = indices[i];
indices[i] = indices[j];
indices[j] = tmp;
}
for (let i = 0; i < count && i < indices.length; i++) masked.add(indices[i]);
break;
}
case 'every_other': {
const start = Math.random() < 0.5 ? 0 : 1;
for (let i = start; i < n; i += 2) masked.add(i);
break;
}
case 'fibonacci': {
const fib = fibonacciIndexSet(n);
for (let i = 0; i < n; i++) if (fib.has(i + 1)) masked.add(i);
break;
}
case 'primes': {
const primes = primeIndexSet(n);
for (let i = 0; i < n; i++) if (primes.has(i + 1)) masked.add(i);
break;
}
case 'wordlen': {
const maxLen = clampNumber(s.mask_word_length, 1, 10, 3);
for (let i = 0; i < n; i++) if (tokens[i].word.length <= maxLen) masked.add(i);
break;
}
case 'rita': {
const RiTa = await loadRiTa();
const cls = RITA_CLASSES.find((c) => c.value === (s.mask_rita_class || 'nouns')) || RITA_CLASSES[0];
for (let i = 0; i < n; i++) {
let tags = null;
try { tags = RiTa.pos(tokens[i].word); } catch (e) { tags = null; }
if (!Array.isArray(tags)) continue;
if (tags.some((tag) => typeof tag === 'string' && cls.tags.some((p) => tag.startsWith(p)))) {
masked.add(i);
}
}
break;
}
default:
return null;
}
return masked;
}
/* Defensive cleanup of streamed diffusion replies: strips zero-width
characters, including the U+2060 payloads the server only sends when
a request opts into includeSteps (always false for this app). */
export function sanitizeDiffusionOutput(content) {
return String(content || '')
.replace(/[\u2060\u200b\ufeff]/g, '')
.trim();
}
/*
* One masked-infill call to a diffusion model, mirroring the Re-imagine
* plugin: system + user prompt condition the refill, the assistant
* message carries the masked text ('…' = holes).
*/
export async function runDiffusionInfill(apiKey, modelName, modelSettings, userPrompt, assistantContent) {
const body = {
model: modelName,
messages: [
{ role: 'system', content: 'Only requested text based on instructions in output - no comments or prefixing' },
{ role: 'user', content: userPrompt },
{ role: 'assistant', content: assistantContent },
],
stream: true,
extra_body: buildModelRequestBody(modelSettings, modelName),
};
let content = '';
for await (const chunk of streamCompletion(apiKey, body)) {
content += chunk;
}
return content;
}
/* Flat sampler settings for buildModelRequestBody from the namespaced
per-kind knobs stored by the auto-typist panel. */
export function extractModelSettings(settings, modelName) {
const prefix = isDiffusionGemmaModel(modelName) ? DIFFUSIONGEMMA_PARAMS_PREFIX : MDLM_PARAMS_PREFIX;
const out = {};
for (const [k, v] of Object.entries(settings || {})) {
if (k.startsWith(prefix)) out[k.slice(prefix.length)] = v;
}
return out;
}
const DIFFUSION_INFILL_FALLBACK_PROMPT = 'Fill in the masked words naturally to improve the sentence. Output only the completed sentence.';
const MAX_CHUNK_ROUNDS = 16;
/*
* Diffusion auto-typist core: mask words of `sentenceText` according to
* the configured pattern, refill them via the diffusion model — once
* for most patterns, once per chunk round for the 'chunk' pattern — and
* return the final text (which may equal the input) or null when
* nothing was produced.
*/
export async function runMaskedAutotype(apiKey, modelName, settings, sentenceText) {
const s = settings || {};
const pattern = s.mask_pattern || 'random';
const userPrompt = String(s.prompt || '').trim() || DIFFUSION_INFILL_FALLBACK_PROMPT;
const modelSettings = extractModelSettings(s, modelName);
/* Mask tokens per masked word (the per-word generation budget for
the refill). Flanking whitespace stays verbatim — see applyMask. */
const perWord = Math.round(
clampNumber(s.mask_tokens_per_word, MASK_TOKENS_PER_WORD.min, MASK_TOKENS_PER_WORD.max, MASK_TOKENS_PER_WORD.default)
);
/* 'chunk': go through the sentence, each round masking the next X
words of the current (refilled) text. */
if (pattern === 'chunk') {
const chunkSize = Math.round(clampNumber(s.mask_chunk_size, 3, 10, 5));
let current = sentenceText;
let nextStart = 0;
for (let round = 0; round < MAX_CHUNK_ROUNDS; round++) {
const tokens = tokenizeWords(current);
if (!tokens.length || nextStart >= tokens.length) break;
const masked = new Set();
for (let i = nextStart; i < Math.min(nextStart + chunkSize, tokens.length); i++) masked.add(i);
nextStart += chunkSize;
const maskedText = applyMask(current, tokens, masked, perWord);
let result;
try {
result = await runDiffusionInfill(apiKey, modelName, modelSettings, userPrompt, maskedText);
} catch (err) {
console.error('Autotype diffusion error (chunk round):', err);
break;
}
const next = sanitizeDiffusionOutput(result);
if (!next) break;
current = next;
}
return current;
}
/* Every other pattern: one masked-infill shot. */
const tokens = tokenizeWords(sentenceText);
if (!tokens.length) return null;
const masked = await computeMaskIndices(tokens, pattern, s);
if (!masked || masked.size === 0) return null;
const maskedText = applyMask(sentenceText, tokens, masked, perWord);
const result = await runDiffusionInfill(apiKey, modelName, modelSettings, userPrompt, maskedText);
const clean = sanitizeDiffusionOutput(result);
return clean || null;
}
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, buildModelRequestBody } 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: buildModelRequestBody(settings.model),
};
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 = {
contextScope: {
name: 'Context',
type: 'context-scope',
default: 'sentence',
hint: 'How much surrounding text is given to the model',
values: [
{ value: 'sentence', title: 'Sentence', hint: 'The sentence(s) containing the selection' },
{ value: 'sibling-sentences', title: 'Sibling sentences', hint: 'Adds the previous and the next sentence' },
{ value: 'paragraph', title: 'Paragraph', hint: 'All sentences of the paragraph containing the selection' },
{ value: 'sibling-paragraphs', title: 'Sibling paragraphs', hint: 'Adds the previous and the next paragraph' },
{ value: 'document', title: 'Document', hint: 'The entire document' },
],
},
prompt: {
name: 'Prompt',
type: 'text',
default: '',
hint: 'Optional instructions for the reimagining',
}
};
openPromptModal({ ...ctx, pluginParams }, executePrompt);
},
[openPromptModal, executePrompt]
);
const aroundHandler = useCallback(
(ctx) => {
const pluginParams = {
contextScope: {
name: 'Context',
type: 'context-scope',
default: 'sentence',
hint: 'How much surrounding text is given to the model',
values: [
{ value: 'sentence', title: 'Sentence', hint: 'The sentence(s) containing the selection' },
{ value: 'sibling-sentences', title: 'Sibling sentences', hint: 'Adds the previous and the next sentence' },
{ value: 'paragraph', title: 'Paragraph', hint: 'All sentences of the paragraph containing the selection' },
{ value: 'sibling-paragraphs', title: 'Sibling paragraphs', hint: 'Adds the previous and the next paragraph' },
{ value: 'document', title: 'Document', hint: 'The entire document' },
],
},
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 = {
contextScope: {
name: 'Context',
type: 'context-scope',
default: 'sentence',
hint: 'How much surrounding text is given to the model',
values: [
{ value: 'sentence', title: 'Sentence', hint: 'The sentence(s) containing the selection' },
{ value: 'sibling-sentences', title: 'Sibling sentences', hint: 'Adds the previous and the next sentence' },
{ value: 'paragraph', title: 'Paragraph', hint: 'All sentences of the paragraph containing the selection' },
{ value: 'sibling-paragraphs', title: 'Sibling paragraphs', hint: 'Adds the previous and the next paragraph' },
{ value: 'document', title: 'Document', hint: 'The entire document' },
],
},
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, { useMemo } from 'react';
import {
ensureApiKey,
streamCompletion,
isDiffusionGemmaModel,
runMaskedAutotype,
RITA_CLASSES,
MDLM_PARAMS_PREFIX,
DIFFUSIONGEMMA_PARAMS_PREFIX,
MASK_TOKENS_PER_WORD,
} from '#App [name="dllm-utils"]';
/* ============================================================
AutotypeLLMPlugin – LLM-powered sentence rewriting suggestions
One plugin, two engines picked by the Model dropdown:
- Transformer LLM (default): rewrite the sentence via a chat
completion (temp / max_tokens / top_p), as before.
- Text-diffusion models: mask words of the sentence following a
masking pattern, refill the holes via the diffusion server
(like the Re-imagine plugins), suggest the result.
============================================================ */
const TRANSFORMER_MODEL = 'cavi/small';
/* Masking patterns for the diffusion models (semantics in
dllm-utils' computeMaskIndices / runMaskedAutotype). */
const MASK_PATTERNS = [
{ value: 'random', title: 'Random', hint: 'Mask a random number of randomly chosen words (bounded by Max % masked)' },
{ value: 'chunk', title: 'X words at a time', hint: 'Go through the sentence, each round remasking and refilling the next X words' },
{ value: 'every_other', title: 'Every other word', hint: 'Mask alternating words in a single pass' },
{ value: 'fibonacci', title: 'Fibonacci', hint: 'Mask words at Fibonacci positions (1, 2, 3, 5, 8, 13, …)' },
{ value: 'primes', title: 'Primes', hint: 'Mask words at prime positions (2, 3, 5, 7, 11, …)' },
{ value: 'wordlen', title: 'Word length', hint: 'Mask all words with at most Max word length characters' },
{ value: 'rita', title: 'RiTa.js word class', hint: 'Mask words of one grammatical class, tagged with RiTa.js' },
];
function humanizeKey(key) {
return key.replace(/_/g, ' ').replace(/^./, (c) => c.toUpperCase());
}
/*
* Built inside the component (NOT at module scope): the model list
* comes from window.availableModels, populated by app.jsx, which
* evaluates after this module. The returned object must stay
* referentially stable across renders, or SetupPanel's value
* re-initialization effect (dep on `params`) would loop.
*/
function createAutotypeParams() {
const availableModels = Array.isArray(window.availableModels) ? window.availableModels : [];
const diffusionModels = availableModels.map((m) => m.name);
const isDiffusionModel = (name) => diffusionModels.includes(name);
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.',
},
model: {
name: 'Model',
type: 'enum',
values: [
{ value: TRANSFORMER_MODEL, title: `${TRANSFORMER_MODEL} (transformer)` },
...diffusionModels.map((name) => ({ value: name, title: name })),
],
default: TRANSFORMER_MODEL,
hint: 'Model used when the auto-typist fires. Transformer models rewrite the whole sentence; diffusion models refill masked words (see Masking pattern).',
},
/* --- Transformer-only settings (the original behavior) --- */
temp: {
type: 'range',
min: 0,
max: 2,
step: 0.1,
default: 0.7,
hint: 'Sampling temperature',
showIf: (v) => !isDiffusionModel(v.model),
},
max_tokens: {
type: 'range',
min: 1,
max: 512,
step: 1,
default: 64,
hint: 'Maximum tokens to generate',
showIf: (v) => !isDiffusionModel(v.model),
},
top_p: {
type: 'range',
min: 0,
max: 1,
step: 0.05,
default: 0.95,
hint: 'Nucleus sampling parameter',
showIf: (v) => !isDiffusionModel(v.model),
},
/* --- Diffusion-only settings --- */
mask_pattern: {
name: 'Masking pattern',
type: 'enum',
values: MASK_PATTERNS,
default: 'random',
showIf: (v) => isDiffusionModel(v.model),
hint: 'Which words of the sentence get masked before the diffusion model refills them',
},
mask_tokens_per_word: {
name: 'Tokens per word',
type: 'range',
min: MASK_TOKENS_PER_WORD.min,
max: MASK_TOKENS_PER_WORD.max,
step: 1,
default: MASK_TOKENS_PER_WORD.default,
showIf: (v) => isDiffusionModel(v.model),
hint: `Mask tokens each masked word becomes (${MASK_TOKENS_PER_WORD.min}–${MASK_TOKENS_PER_WORD.max}): the per-word generation budget for the refill. Whitespace around masked words stays in the text as-is`,
},
mask_random_percent: {
name: 'Max % masked',
type: 'range',
min: 5,
max: 50,
step: 1,
default: 25,
showIf: (v) => isDiffusionModel(v.model) && (v.mask_pattern || 'random') === 'random',
hint: 'Upper bound on the number of masked words, as a percentage of the sentence; the actual number is rolled randomly on every trigger',
},
mask_chunk_size: {
name: 'Words per round',
type: 'range',
min: 3,
max: 10,
step: 1,
default: 5,
showIf: (v) => isDiffusionModel(v.model) && v.mask_pattern === 'chunk',
hint: 'Each round masks the next X words of the sentence and refills them (capped at 16 rounds)',
},
mask_word_length: {
name: 'Max word length',
type: 'range',
min: 1,
max: 10,
step: 1,
default: 3,
showIf: (v) => isDiffusionModel(v.model) && v.mask_pattern === 'wordlen',
hint: 'Mask every word with at most this many characters',
},
mask_rita_class: {
name: 'Word class',
type: 'enum',
values: RITA_CLASSES.map((c) => ({
value: c.value,
title: c.title,
hint: `Mask words RiTa.js tags as ${c.title.toLowerCase()}`,
})),
default: 'nouns',
showIf: (v) => isDiffusionModel(v.model) && v.mask_pattern === 'rita',
hint: 'RiTa.js POS class of words to mask (the library loads from CDN on first use)',
},
};
/* Sampler knobs of the diffusion models, namespaced per model kind
(mdlm.* / gemma.*) so keys shared by both kinds (steps, max_tokens,
seed) don't collide. One namespace per kind, shared by all models
of that kind. */
const seenKinds = new Set();
for (const m of availableModels) {
const isGemma = isDiffusionGemmaModel(m.name);
const prefix = isGemma ? DIFFUSIONGEMMA_PARAMS_PREFIX : MDLM_PARAMS_PREFIX;
if (seenKinds.has(prefix)) continue;
seenKinds.add(prefix);
for (const [key, spec] of Object.entries(m.params || {})) {
params[prefix + key] = {
...spec,
name: spec.name || humanizeKey(key),
showIf: (v) =>
isDiffusionModel(v.model) &&
(isGemma ? isDiffusionGemmaModel(v.model) : !isDiffusionGemmaModel(v.model)),
};
}
}
/* Shared prompt, last in the panel: guides the transformer rewrite
and, when the diffusion model supports it, the masked refill. */
params.prompt = {
type: 'text',
rows: 3,
default: 'Vivid words',
hint: 'Instruction sent to the model: the transformer rewrites the sentence by it; diffusion models refill the masked words according to it (if supported)',
};
return params;
}
export function AutotypeLLMPlugin({ useEditor, useAutotypePlugin }) {
const { addSuggestion, getAutotypeSettings } = useEditor();
/* Stable param specs (see the note on createAutotypeParams). */
const params = useMemo(() => createAutotypeParams(), []);
useAutotypePlugin('autotype/llm', async ({ sentenceText, anchorId, trigger }) => {
if (!sentenceText?.trim()) return;
const settings = getAutotypeSettings('autotype/llm');
// Only apply chance for auto trigger; manual trigger (Ctrl+Space) always runs
if (trigger === 'auto') {
const chance = settings.chance ?? 10;
const roll = Math.random() * 100;
if (roll > chance) return;
}
const diffusionNames = (Array.isArray(window.availableModels) ? window.availableModels : [])
.map((m) => m.name);
const savedModel = settings.model || TRANSFORMER_MODEL;
const modelName =
savedModel === TRANSFORMER_MODEL || diffusionNames.includes(savedModel)
? savedModel
: TRANSFORMER_MODEL;
/* --- Diffusion path: mask words, refill like Re-imagine --- */
if (modelName !== TRANSFORMER_MODEL) {
const apiKey = ensureApiKey();
let suggested = null;
try {
suggested = await runMaskedAutotype(apiKey, modelName, settings, sentenceText);
} catch (err) {
console.error('Autotype diffusion error:', err);
}
suggested = String(suggested || '').trim();
if (!suggested || suggested === sentenceText.trim()) return;
addSuggestion({
id: `sugg-at-dllm-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`,
anchorId,
originalText: sentenceText,
suggestedText: suggested,
});
return;
}
/* --- Transformer path (original behavior) --- */
const userPrompt = settings.prompt ?? 'Rewrite this sentence to be more clear and concise.';
const temp = settings.temp ?? 0.7;
const max_tokens = settings.max_tokens ?? 64;
const top_p = settings.top_p ?? 0.95;
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;
}
}