` node's children from a Shiki hast root, skipping the
+ * outer `` and `` wrappers (their styles would leak onto tokens).
+ */
+function getCodeChildren(hast: HastRoot): readonly ElementContent[] {
+ const pre = hast.children.find((c): c is HastElement => c.type === "element" && c.tagName === "pre");
+ if (!pre) return [];
+ const code = pre.children.find((c): c is HastElement => c.type === "element" && c.tagName === "code");
+ return code ? code.children : [];
+}
+
+function isLineElement(node: ElementContent): node is HastElement {
+ if (node.type !== "element") return false;
+ return readHastClasses(node).includes("line");
+}
+
+function buildLineNumberWidget(n: number): HTMLSpanElement {
+ const span = document.createElement("span");
+ span.className = "code-line-num";
+ span.textContent = String(n);
+ span.setAttribute("contenteditable", "false");
+ span.setAttribute("aria-hidden", "true");
+ return span;
+}
+
+interface DecorationBuildCtx {
+ doc: ProsemirrorNode;
+ name: string;
+ highlighter: ShikiHighlighter | null;
+ defaultLanguage: string | null;
+}
+
+function buildDecorations({ doc, name, highlighter, defaultLanguage }: DecorationBuildCtx): DecorationSet {
+ const decorations: Decoration[] = [];
+ const blocks = findChildren(doc, (node) => node.type.name === name);
+
+ for (const block of blocks) {
+ const text = block.node.textContent;
+
+ // Always emit line-number widgets, even before the highlighter resolves,
+ // so the gutter appears immediately on page load instead of flashing in
+ // ~100ms later when Shiki is ready. Each widget captures its own line
+ // number via a block-scoped const to avoid the classic let-in-closure
+ // pitfall (all widgets would otherwise render the final line number).
+ {
+ const n = 1;
+ const pos = block.pos + 1;
+ decorations.push(
+ Decoration.widget(pos, () => buildLineNumberWidget(n), {
+ side: -1,
+ key: `cln-${block.pos}-${n}`,
+ ignoreSelection: true,
+ }),
+ );
+ }
+ let lineNo = 1;
+ for (let i = 0; i < text.length; i++) {
+ if (text.charCodeAt(i) === 10 /* \n */) {
+ lineNo += 1;
+ const n = lineNo;
+ const pos = block.pos + 1 + i + 1;
+ decorations.push(
+ Decoration.widget(pos, () => buildLineNumberWidget(n), {
+ side: -1,
+ key: `cln-${block.pos}-${n}`,
+ ignoreSelection: true,
+ }),
+ );
+ }
+ }
+
+ if (!highlighter || !text) continue;
+
+ const raw = block.node.attrs.language || defaultLanguage || "";
+ const lang = normalizeLang(raw);
+
+ let hast: HastRoot;
+ try {
+ hast = highlighter.codeToHast(text, {
+ lang: isSupportedLang(lang) ? lang : "text",
+ themes: SHIKI_THEMES,
+ defaultColor: false,
+ }) as HastRoot;
+ } catch {
+ continue;
+ }
+
+ const children = getCodeChildren(hast);
+ let offset = block.pos + 1;
+ for (const child of children) {
+ if (child.type === "text") {
+ offset += child.value.length;
+ continue;
+ }
+ if (!isLineElement(child)) continue;
+ offset = walkLineTokens(child.children, offset, decorations);
+ }
+ }
+
+ return DecorationSet.create(doc, decorations);
+}
+
+const shikiPluginKey = new PluginKey("shiki");
+
+function createShikiPlugin(name: string, defaultLanguage: string | null): Plugin {
+ let highlighter: ShikiHighlighter | null = null;
+
+ const build = (doc: ProsemirrorNode) =>
+ buildDecorations({ doc, name, highlighter, defaultLanguage });
+
+ return new Plugin({
+ key: shikiPluginKey,
+ state: {
+ init: (_cfg, { doc }) => build(doc),
+ apply(tr, decorationSet, oldState, newState) {
+ if (tr.getMeta(shikiPluginKey)?.refresh) return build(tr.doc);
+
+ const oldNodeName = oldState.selection.$head.parent.type.name;
+ const newNodeName = newState.selection.$head.parent.type.name;
+ const oldNodes = findChildren(oldState.doc, (node) => node.type.name === name);
+ const newNodes = findChildren(newState.doc, (node) => node.type.name === name);
+
+ const needsRebuild =
+ tr.docChanged &&
+ ([oldNodeName, newNodeName].includes(name) ||
+ newNodes.length !== oldNodes.length ||
+ tr.steps.some((step) => {
+ const s = step as unknown as { from?: number; to?: number };
+ return (
+ s.from !== undefined &&
+ s.to !== undefined &&
+ oldNodes.some((n) => n.pos >= (s.from as number) && n.pos + n.node.nodeSize <= (s.to as number))
+ );
+ }));
+
+ if (needsRebuild) return build(tr.doc);
+ return decorationSet.map(tr.mapping, tr.doc);
+ },
+ },
+ props: {
+ decorations(state) {
+ return shikiPluginKey.getState(state);
+ },
+ },
+ view(view: EditorView) {
+ let cancelled = false;
+ getSharedHighlighter().then((h) => {
+ if (cancelled) return;
+ highlighter = h;
+ if (!view.isDestroyed) {
+ view.dispatch(view.state.tr.setMeta(shikiPluginKey, { refresh: true }));
+ }
+ });
+ return {
+ destroy() {
+ cancelled = true;
+ },
+ };
+ },
+ });
+}
+
+export const CodeBlockShiki = CodeBlock.extend({
+ addProseMirrorPlugins() {
+ const parentPlugins = this.parent?.() || [];
+ return [
+ ...parentPlugins,
+ createShikiPlugin(this.name, this.options.defaultLanguage ?? null),
+ ];
+ },
+ addNodeView() {
+ return ({ node, HTMLAttributes }) => {
+ const pre = document.createElement("pre");
+ pre.classList.add("shiki");
+ for (const [key, value] of Object.entries(HTMLAttributes)) {
+ if (value !== undefined && value !== null && value !== false) {
+ pre.setAttribute(key, String(value));
+ }
+ }
+
+ const code = document.createElement("code");
+ pre.append(code);
+
+ const applyLang = (n: ProsemirrorNode) => {
+ const raw = (n.attrs.language as string | null | undefined) || "";
+ const normalized = raw ? normalizeLang(raw) : "";
+ if (normalized) {
+ pre.dataset.lang = normalized;
+ code.className = `language-${normalized}`;
+ } else {
+ delete pre.dataset.lang;
+ code.className = "";
+ }
+ };
+
+ applyLang(node);
+
+ return {
+ dom: pre,
+ contentDOM: code,
+ update(updatedNode) {
+ if (updatedNode.type.name !== "codeBlock") return false;
+ applyLang(updatedNode);
+ return true;
+ },
+ };
+ };
+ },
+});
diff --git a/frontend/src/editor/extensions/collaboration-cursor-v3.ts b/frontend/src/editor/extensions/collaboration-cursor-v3.ts
index e259f5b9420d746c5d02615fcf3e8138c7709d40..896d9b33e539aecbae3a72025ee132889cc5b5cc 100644
--- a/frontend/src/editor/extensions/collaboration-cursor-v3.ts
+++ b/frontend/src/editor/extensions/collaboration-cursor-v3.ts
@@ -63,6 +63,10 @@ export const CollaborationCursorV3 = Extension.create` to satisfy `Partial`. We don't need
+ // that here: the command is used internally via `editor.commands.updateUser`
+ // with a runtime cast, so we widen the return type to keep typing simple.
return {
updateUser:
(attributes: Record) =>
@@ -71,7 +75,7 @@ export const CollaborationCursorV3 = Extension.create { _blockScroll = false; });
+ }
+ return true;
+ },
+ }),
+ ];
+ },
+
+ addOptions() {
+ return {};
+ },
+
+ onBeforeCreate() {
+ const existing = this.editor.options.editorProps?.handleScrollToSelection;
+
+ this.editor.setOptions({
+ editorProps: {
+ ...this.editor.options.editorProps,
+ handleScrollToSelection(view) {
+ if (_blockScroll) return true;
+ if (existing) return existing.call(this, view);
+ return false;
+ },
+ },
+ });
+ },
+});
diff --git a/frontend/src/editor/extensions/stack.ts b/frontend/src/editor/extensions/stack.ts
index 2a79a12a68a0035b9a5b887d962237e55a3a70c8..39f942358e97b64abad5798dce5264d780ef8a9e 100644
--- a/frontend/src/editor/extensions/stack.ts
+++ b/frontend/src/editor/extensions/stack.ts
@@ -6,6 +6,7 @@ declare module "@tiptap/core" {
interface Commands {
stack: {
insertStack: (columns?: number) => ReturnType;
+ setStackColumns: (pos: number, columns: number) => ReturnType;
};
}
}
@@ -67,6 +68,42 @@ export const Stack = Node.create({
content: cols,
});
},
+
+ setStackColumns:
+ (pos, columns) =>
+ ({ tr, state, dispatch }) => {
+ const target = Math.min(Math.max(columns, 2), 4);
+ const node = state.doc.nodeAt(pos);
+ if (!node || node.type.name !== "stack") return false;
+
+ const current = node.childCount;
+ if (current === target) {
+ tr.setNodeMarkup(pos, undefined, { ...node.attrs, layout: `${target}-column` });
+ dispatch?.(tr);
+ return true;
+ }
+
+ tr.setNodeMarkup(pos, undefined, { ...node.attrs, layout: `${target}-column` });
+
+ if (target > current) {
+ const colType = state.schema.nodes.stackColumn;
+ const pType = state.schema.nodes.paragraph;
+ const insertPos = pos + node.nodeSize - 1;
+ for (let i = 0; i < target - current; i++) {
+ tr.insert(insertPos + i, colType.create(null, pType.create()));
+ }
+ } else {
+ let removeFrom = pos + 1;
+ for (let i = 0; i < target; i++) {
+ removeFrom += node.child(i).nodeSize;
+ }
+ const removeTo = pos + node.nodeSize - 1;
+ tr.delete(removeFrom, removeTo);
+ }
+
+ dispatch?.(tr);
+ return true;
+ },
};
},
diff --git a/frontend/src/editor/frontmatter/FrontmatterHero.tsx b/frontend/src/editor/frontmatter/FrontmatterHero.tsx
index 1a40b2fb0937d9413ebf5a7b38e48b4e793dbda6..3fcf3ec22e7da1820822d9df2e8ada28d0fc6855 100644
--- a/frontend/src/editor/frontmatter/FrontmatterHero.tsx
+++ b/frontend/src/editor/frontmatter/FrontmatterHero.tsx
@@ -1,18 +1,86 @@
-import { useState, useRef, useEffect, type KeyboardEvent } from "react";
+import { useState, useRef, useEffect, useMemo, type KeyboardEvent } from "react";
import { Tooltip } from "../../components/Tooltip";
-import { Plus, X } from "lucide-react";
+import { Plus, ImagePlus, BarChart3, Trash2 } from "lucide-react";
import { useFrontmatter } from "./useFrontmatter";
+import { buildDoc } from "../embeds/build-doc";
+import { useTheme } from "../../hooks/useTheme";
+import { uploadImage } from "../upload";
import type { FrontmatterStore, Author, Affiliation } from "./frontmatter-store";
+import type { EmbedStore } from "../embeds/embed-store";
+
+const BANNER_KEY = "banner.html";
+
+const CHART_PLACEHOLDER_HTML = ``;
+
+function makeImageBannerHtml(url: string) {
+ return `
+
+
`;
+}
interface FrontmatterHeroProps {
store: FrontmatterStore | null;
+ embedStore?: EmbedStore | null;
}
-export function FrontmatterHero({ store }: FrontmatterHeroProps) {
+export function FrontmatterHero({ store, embedStore }: FrontmatterHeroProps) {
const { data, update } = useFrontmatter(store);
const [editingAuthorIdx, setEditingAuthorIdx] = useState(null);
const [showAuthorForm, setShowAuthorForm] = useState(false);
+ const bannerSrc = data?.banner || "";
+ const [bannerHtml, setBannerHtml] = useState("");
+ const [uploadingBanner, setUploadingBanner] = useState(false);
+ const bannerFileRef = useRef(null);
+
+ useEffect(() => {
+ if (!embedStore || !bannerSrc) { setBannerHtml(""); return; }
+ setBannerHtml(embedStore.get(bannerSrc));
+ return embedStore.observeKey(bannerSrc, setBannerHtml);
+ }, [embedStore, bannerSrc]);
+
+ const { isDark, primaryColor } = useTheme();
+ const bannerSrcdoc = useMemo(() => {
+ if (!bannerHtml) return "";
+ // Banner iframes are size-constrained by the parent (5:2 aspect ratio),
+ // so we render them edge-to-edge: no body padding, full height chain.
+ // primaryColor must be forwarded so banners using `--primary-color`
+ // pick up the article's accent (otherwise they fall back to the
+ // generic #4e79a7 from buildEmbedSrcdoc).
+ return buildDoc(bannerHtml, { isDark, primaryColor, fullBleed: true });
+ }, [bannerHtml, isDark, primaryColor]);
+
+ const handleBannerUpload = async (file: File) => {
+ if (!embedStore || !file.type.startsWith("image/")) return;
+ setUploadingBanner(true);
+ try {
+ const url = await uploadImage(file);
+ embedStore.set(BANNER_KEY, makeImageBannerHtml(url));
+ update("banner", BANNER_KEY);
+ } catch (err) {
+ console.error("[banner-upload]", err);
+ } finally {
+ setUploadingBanner(false);
+ }
+ };
+
+ const handleCreateChart = () => {
+ if (!embedStore) return;
+ embedStore.set(BANNER_KEY, CHART_PLACEHOLDER_HTML);
+ update("banner", BANNER_KEY);
+ window.dispatchEvent(
+ new CustomEvent("open-embed-studio", { detail: { src: BANNER_KEY } }),
+ );
+ };
+
+ const handleRemoveBanner = () => {
+ if (!embedStore) return;
+ embedStore.remove(bannerSrc || BANNER_KEY);
+ update("banner", "");
+ };
+
if (!data) {
return
;
}
@@ -20,7 +88,6 @@ export function FrontmatterHero({ store }: FrontmatterHeroProps) {
const hasAuthors = data.authors.length > 0;
const hasAffiliations = data.affiliations.length > 0;
const multipleAffiliations = data.affiliations.length > 1;
- const hasMeta = hasAuthors || data.published || data.doi;
return (
@@ -33,19 +100,81 @@ export function FrontmatterHero({ store }: FrontmatterHeroProps) {
multiline
/>
+ {bannerSrcdoc ? (
+
+
+ {embedStore && (
+
+
+
+
+
+ )}
+
+ ) : embedStore ? (
+
+
{
+ const file = e.target.files?.[0];
+ if (file) handleBannerUpload(file);
+ e.target.value = "";
+ }}
+ />
+
+
+ Add a banner to set the mood of your article.
+
+
+ bannerFileRef.current?.click()}
+ >
+
+ {uploadingBanner ? "Uploading..." : "Upload image"}
+
+
+
+ Create chart
+
+
+
+
+ ) : null}
+
update("subtitle", v)}
className="hero-desc"
+ multiline
/>
- {hasMeta && (
-
-
-
-
Authors
+
+
+
+
Authors
+ {hasAuthors ? (
{data.authors.map((author, i) => (
@@ -80,80 +209,78 @@ export function FrontmatterHero({ store }: FrontmatterHeroProps) {
-
-
- {hasAffiliations && (
-
-
Affiliations
- {multipleAffiliations ? (
-
- {data.affiliations.map((aff, i) => (
-
- {aff.url ? (
-
- {aff.name}
-
- ) : (
- aff.name
- )}
-
- ))}
-
- ) : (
-
- {data.affiliations[0].url ? (
-
- {data.affiliations[0].name}
-
- ) : (
- data.affiliations[0].name
- )}
-
- )}
-
+ ) : (
+
setShowAuthorForm(true)}
+ >
+
+ Add author
+
)}
+
+ {hasAffiliations && (
-
Published
-
update("published", v)}
- inline
- />
+ Affiliations
+ {multipleAffiliations ? (
+
+ {data.affiliations.map((aff, i) => (
+
+ {aff.url ? (
+
+ {aff.name}
+
+ ) : (
+ aff.name
+ )}
+
+ ))}
+
+ ) : (
+
+ {data.affiliations[0].url ? (
+
+ {data.affiliations[0].name}
+
+ ) : (
+ data.affiliations[0].name
+ )}
+
+ )}
+ )}
-
-
DOI
- update("doi", v)}
- inline
- />
-
+
+
Published
+ update("published", v)}
+ inline
+ />
-
- )}
+
+
-
- {showAuthorForm && (
-
{
- if (newAff) {
- const idx = store!.addAffiliation(newAff);
- author.affiliations = [...author.affiliations, idx];
- }
- store?.addAuthor(author);
- setShowAuthorForm(false);
- }}
- onCancel={() => setShowAuthorForm(false)}
- />
- )}
+ {
+ if (newAff) {
+ const idx = store!.addAffiliation(newAff);
+ author.affiliations = [...author.affiliations, idx];
+ }
+ store?.addAuthor(author);
+ setShowAuthorForm(false);
+ }}
+ onCancel={() => setShowAuthorForm(false)}
+ />
+
{editingAuthorIdx !== null && data.authors[editingAuthorIdx] && (
{
@@ -182,93 +309,71 @@ interface EditableTextProps {
}
function EditableText({ value, placeholder, onChange, className, multiline, inline }: EditableTextProps) {
- const [editing, setEditing] = useState(false);
const [draft, setDraft] = useState(value);
const inputRef = useRef(null);
useEffect(() => {
- if (!editing) setDraft(value);
- }, [value, editing]);
+ setDraft(value);
+ }, [value]);
useEffect(() => {
- if (editing) {
- inputRef.current?.focus();
- if (inputRef.current) {
- const el = inputRef.current;
- el.selectionStart = el.selectionEnd = el.value.length;
- }
+ if (multiline && inputRef.current instanceof HTMLTextAreaElement) {
+ autoResize(inputRef.current);
}
- }, [editing]);
+ }, [draft, multiline]);
+
+ const autoResize = (el: HTMLTextAreaElement) => {
+ el.style.height = "0";
+ el.style.height = `${el.scrollHeight}px`;
+ };
const commit = () => {
- setEditing(false);
if (draft !== value) onChange(draft);
};
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === "Enter" && !multiline) {
e.preventDefault();
- commit();
+ (e.target as HTMLElement).blur();
}
if (e.key === "Escape") {
setDraft(value);
- setEditing(false);
+ (e.target as HTMLElement).blur();
}
};
- const displayValue = value || placeholder;
- const isEmpty = !value;
-
- if (editing) {
- const Tag = multiline ? "textarea" : "input";
- return (
-
- setDraft(e.target.value)}
- onBlur={commit}
- onKeyDown={handleKeyDown}
- placeholder={placeholder}
- style={{
- font: "inherit",
- color: "inherit",
- textAlign: "inherit",
- background: "transparent",
- border: "none",
- padding: 0,
- margin: 0,
- width: "100%",
- outline: "none",
- resize: multiline ? "vertical" : "none",
- }}
- />
-
- );
- }
+ const Tag = multiline ? "textarea" : "input";
+ const inputCls = inline
+ ? "editable-text-input editable-text-input--inline"
+ : "editable-text-input";
return (
setEditing(true)}
- style={{
- display: inline ? "inline" : "block",
- opacity: isEmpty ? 0.35 : 1,
- cursor: "text",
- }}
+ style={{ display: inline ? "inline" : "block" }}
>
- {displayValue}
+ setDraft(e.target.value)}
+ onBlur={commit}
+ onKeyDown={handleKeyDown}
+ placeholder={placeholder}
+ rows={multiline ? 1 : undefined}
+ />
);
}
function AuthorInlineForm({
+ open,
initial,
affiliations,
onSubmit,
onCancel,
}: {
+ open: boolean;
initial?: Author;
affiliations: Affiliation[];
onSubmit: (author: Author, newAffiliation?: Affiliation) => void;
@@ -279,10 +384,30 @@ function AuthorInlineForm({
const [affIndices, setAffIndices] = useState(initial?.affiliations || []);
const [newAffName, setNewAffName] = useState("");
const nameRef = useRef(null);
+ const dialogRef = useRef(null);
useEffect(() => {
- nameRef.current?.focus();
- }, []);
+ const dialog = dialogRef.current;
+ if (!dialog) return;
+ if (open && !dialog.open) {
+ setName(initial?.name || "");
+ setUrl(initial?.url || "");
+ setAffIndices(initial?.affiliations || []);
+ setNewAffName("");
+ dialog.showModal();
+ setTimeout(() => nameRef.current?.focus(), 50);
+ } else if (!open && dialog.open) {
+ dialog.close();
+ }
+ }, [open, initial]);
+
+ useEffect(() => {
+ const dialog = dialogRef.current;
+ if (!dialog) return;
+ const onClose = () => onCancel();
+ dialog.addEventListener("close", onClose);
+ return () => dialog.removeEventListener("close", onClose);
+ }, [onCancel]);
const handleSubmit = () => {
if (!name.trim()) return;
@@ -293,6 +418,11 @@ function AuthorInlineForm({
);
};
+ const handleCancel = () => {
+ dialogRef.current?.close();
+ onCancel();
+ };
+
const toggleAff = (idx: number) => {
setAffIndices((prev) =>
prev.includes(idx) ? prev.filter((i) => i !== idx) : [...prev, idx],
@@ -300,54 +430,55 @@ function AuthorInlineForm({
};
return (
-
-
+
+
{initial ? "Edit author" : "Add author"}
-
+
+
+
+ setName(e.target.value)}
+ onKeyDown={(e) => e.key === "Enter" && handleSubmit()}
+ />
+ setUrl(e.target.value)}
+ onKeyDown={(e) => e.key === "Enter" && handleSubmit()}
+ />
+
+
+ {affiliations.length > 0 && (
+
+ {affiliations.map((aff, i) => (
+ toggleAff(i + 1)}
+ >
+ {i + 1}. {aff.name}
+
+ ))}
+
+ )}
-
- setName(e.target.value)}
- onKeyDown={(e) => e.key === "Enter" && handleSubmit()}
- />
setUrl(e.target.value)}
+ placeholder="New affiliation (optional), e.g. Hugging Face"
+ value={newAffName}
+ onChange={(e) => setNewAffName(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && handleSubmit()}
+ style={{ marginTop: 12 }}
/>
-
- {affiliations.length > 0 && (
-
- {affiliations.map((aff, i) => (
- toggleAff(i + 1)}
- >
- {i + 1}. {aff.name}
-
- ))}
-
- )}
-
-
setNewAffName(e.target.value)}
- onKeyDown={(e) => e.key === "Enter" && handleSubmit()}
- />
-
-
+
);
}
diff --git a/frontend/src/editor/frontmatter/HueSlider.tsx b/frontend/src/editor/frontmatter/HueSlider.tsx
index 27432a1bff133e5bc15e258fd690fc84c7afd09d..3535be7400a1d685579cde620e860c1e18b34c27 100644
--- a/frontend/src/editor/frontmatter/HueSlider.tsx
+++ b/frontend/src/editor/frontmatter/HueSlider.tsx
@@ -1,7 +1,5 @@
import { useRef, useState, useCallback } from "react";
-
-const L = 0.75;
-const C = 0.12;
+import { OKLCH_L, OKLCH_C } from "#shared/theme";
const COLOR_NAMES = [
{ name: "Candy Apple Red", hue: 0 },
@@ -87,7 +85,7 @@ export function HueSlider({ hue, onChange }: HueSliderProps) {
const sliderRef = useRef
(null);
const [dragging, setDragging] = useState(false);
- const hexColor = oklchToHex(L, C, hue);
+ const hexColor = oklchToHex(OKLCH_L, OKLCH_C, hue);
const colorName = getColorName(hue);
const hueFromClientX = useCallback((clientX: number) => {
@@ -184,4 +182,4 @@ export function HueSlider({ hue, onChange }: HueSliderProps) {
);
}
-export { oklchToHex, L as OKLCH_L, C as OKLCH_C };
+export { oklchToHex };
diff --git a/frontend/src/editor/frontmatter/SettingsDrawer.tsx b/frontend/src/editor/frontmatter/SettingsDrawer.tsx
index ac55e7c9bdeed44d010db6968ad36484d9bbfe49..b0b5524c3c1288fed397f2a662a158e3c6a08e12 100644
--- a/frontend/src/editor/frontmatter/SettingsDrawer.tsx
+++ b/frontend/src/editor/frontmatter/SettingsDrawer.tsx
@@ -130,6 +130,15 @@ export function SettingsDrawer({ open, onClose, store, settingsMap }: SettingsDr
+
+ update("doi", e.target.value)}
+ />
+
+