Skip to content

Commit 7d2ecec

Browse files
trangdoan982claude
andauthored
[ENG-1647] Auto-resize content input and add fileName character limit in node creation dialog (#977)
* [ENG-1647] Auto-resize content input and add 512-char limit in node creation dialog - Convert Content <input> to <textarea> with auto-resize on each keystroke - Add maxLength={512} matching Obsidian's filename character limit - Show inline error message when character limit is reached - Convert locked state <input> to <textarea> for visual consistency Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * Update character limit from 512 to 255 to match OS filesystem limit Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * Extract character limit to MAX_NODE_TITLE_LENGTH constant Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * move order * address PR comments * address PR comments * lint * Refactor: move trim logic from useEffect into handleNodeTypeChange Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * Fix: clear selectedFileRef on node type change to prevent stale async override Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent cd9e6b6 commit 7d2ecec

1 file changed

Lines changed: 63 additions & 11 deletions

File tree

apps/obsidian/src/components/ModifyNodeModal.tsx

Lines changed: 63 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,31 @@ import type DiscourseGraphPlugin from "~/index";
1313
import { QueryEngine } from "~/services/QueryEngine";
1414
import { isProvisionalSchema } from "~/utils/typeUtils";
1515
import { getNodeTypeIdForFile } from "~/utils/relationsStore";
16+
import { formatNodeName } from "~/utils/createNode";
17+
18+
// APFS and ext4 both enforce a 255 UTF-8 byte limit per filename component.
19+
const MAX_FILENAME_BYTES = 255;
20+
const MD_EXTENSION_BYTES = 3; // ".md"
21+
22+
const getByteLength = (str: string): number =>
23+
new TextEncoder().encode(str).byteLength;
24+
25+
// Remove characters from the end until the string fits within maxBytes.
26+
const trimToByteLimit = (str: string, maxBytes: number): string => {
27+
if (getByteLength(str) <= maxBytes) return str;
28+
let trimmed = str;
29+
while (trimmed.length > 0 && getByteLength(trimmed) > maxBytes) {
30+
trimmed = trimmed.slice(0, -1);
31+
}
32+
return trimmed;
33+
};
34+
35+
const computeMaxTitleBytes = (nodeType: DiscourseNode | null): number => {
36+
const formatOverhead = nodeType
37+
? getByteLength(formatNodeName("", nodeType) ?? "")
38+
: 0;
39+
return Math.max(1, MAX_FILENAME_BYTES - MD_EXTENSION_BYTES - formatOverhead);
40+
};
1641

1742
type ModifyNodeFormProps = {
1843
nodeTypes: DiscourseNode[];
@@ -59,12 +84,17 @@ export const ModifyNodeForm = ({
5984
string | undefined
6085
>(undefined);
6186
const queryEngine = useRef(new QueryEngine(plugin.app));
62-
const titleInputRef = useRef<HTMLInputElement>(null);
87+
const titleInputRef = useRef<HTMLTextAreaElement>(null);
6388
const popoverRef = useRef<HTMLDivElement>(null);
6489
const menuRef = useRef<HTMLUListElement>(null);
6590
const debounceTimeoutRef = useRef<number | null>(null);
6691
const selectedFileRef = useRef<TFile | null>(null);
6792

93+
const maxTitleBytes = useMemo(
94+
() => computeMaxTitleBytes(selectedNodeType),
95+
[selectedNodeType],
96+
);
97+
6898
// Search for nodes when query changes (only in create mode)
6999
useEffect(() => {
70100
if (isEditMode) {
@@ -131,7 +161,7 @@ export const ModifyNodeForm = ({
131161
popover.style.left = `${inputRect.left}px`;
132162
popover.style.width = `${inputRect.width}px`;
133163
}
134-
}, [isOpen]);
164+
}, [isOpen, query]);
135165

136166
useEffect(() => {
137167
if (menuRef.current && isOpen && activeIndex >= 0) {
@@ -152,6 +182,13 @@ export const ModifyNodeForm = ({
152182
titleInputRef.current?.focus();
153183
}, []);
154184

185+
useEffect(() => {
186+
const el = titleInputRef.current;
187+
if (!el) return;
188+
el.style.height = "auto";
189+
el.style.height = `${el.scrollHeight}px`;
190+
}, [query]);
191+
155192
// Determine available relationships based on current file and selected node type
156193
const availableRelationships = useMemo(() => {
157194
if (!currentFile || !selectedNodeType || isEditMode) {
@@ -256,7 +293,7 @@ export const ModifyNodeForm = ({
256293
}, 50);
257294
}, []);
258295

259-
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
296+
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
260297
if (selectedExistingNode) {
261298
// If locked, only handle Escape
262299
if (e.key === "Escape") {
@@ -292,16 +329,26 @@ export const ModifyNodeForm = ({
292329
const newSelectedType =
293330
nodeTypes.find((nt) => nt.id === selectedId) || null;
294331
setSelectedNodeType(newSelectedType);
332+
295333
if (selectedExistingNode) {
334+
selectedFileRef.current = null;
296335
setSelectedExistingNode(null);
297336
setQuery("");
298337
setTitle("");
338+
} else {
339+
const newMaxBytes = computeMaxTitleBytes(newSelectedType);
340+
if (getByteLength(query) > newMaxBytes) {
341+
const trimmed = trimToByteLimit(query, newMaxBytes);
342+
setQuery(trimmed);
343+
setTitle(trimmed);
344+
}
299345
}
346+
300347
setSelectedRelationshipKey(undefined);
301348
};
302349

303-
const handleQueryChange = (e: React.ChangeEvent<HTMLInputElement>) => {
304-
const newQuery = e.target.value;
350+
const handleQueryChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
351+
const newQuery = trimToByteLimit(e.target.value, maxTitleBytes);
305352
setQuery(newQuery);
306353
setTitle(newQuery);
307354
if (selectedExistingNode) {
@@ -377,12 +424,12 @@ export const ModifyNodeForm = ({
377424
{selectedExistingNode ? (
378425
// Locked state: show selected node with clear button
379426
<div className="relative flex w-full items-start">
380-
<input
381-
type="text"
427+
<textarea
382428
value={selectedExistingNode.basename}
383429
readOnly
384430
disabled={isSubmitting}
385-
className="resize-vertical font-inherit border-background-modifier-border bg-background-secondary text-text-normal max-h-[6em] min-h-[2.5em] w-full cursor-default overflow-y-auto rounded-md border p-2 pr-8"
431+
rows={1}
432+
className="font-inherit border-background-modifier-border bg-background-secondary text-text-normal min-h-[2.5em] w-full cursor-default resize-none overflow-y-auto rounded-md border p-2 pr-8"
386433
/>
387434
<button
388435
onClick={handleClearSelection}
@@ -397,9 +444,8 @@ export const ModifyNodeForm = ({
397444
) : (
398445
// Search input with popover (only in create mode)
399446
<div className="relative w-full">
400-
<input
447+
<textarea
401448
ref={titleInputRef}
402-
type="text"
403449
placeholder={
404450
isEditMode
405451
? "Enter new content"
@@ -419,9 +465,15 @@ export const ModifyNodeForm = ({
419465
setTimeout(() => setIsFocused(false), 200);
420466
}}
421467
disabled={isSubmitting}
422-
className="resize-vertical font-inherit border-background-modifier-border bg-background-primary text-text-normal max-h-[6em] min-h-[2.5em] w-full overflow-y-auto rounded-md border p-2"
468+
rows={1}
469+
className="font-inherit border-background-modifier-border bg-background-primary text-text-normal min-h-[2.5em] w-full resize-none overflow-hidden rounded-md border p-2"
423470
autoComplete="off"
424471
/>
472+
{getByteLength(query) >= maxTitleBytes && (
473+
<p className="text-error mt-1 text-xs">
474+
Character limit reached
475+
</p>
476+
)}
425477
{isOpen && !isEditMode && (
426478
<div
427479
ref={popoverRef}

0 commit comments

Comments
 (0)