mirror of
https://github.com/alkimake/paperclip.git
synced 2026-06-17 03:10:38 +09:00
Refine issue workflow surfaces and live updates
This commit is contained in:
parent
b4a58ba8a6
commit
03dff1a29a
48 changed files with 2800 additions and 1163 deletions
|
|
@ -297,6 +297,102 @@ function autocompleteMarkdown(option: AutocompleteOption): string {
|
|||
return option.kind === "skill" ? skillMarkdown(option) : mentionMarkdown(option);
|
||||
}
|
||||
|
||||
export function shouldAcceptAutocompleteKey(
|
||||
key: string,
|
||||
trigger: MentionState["trigger"] | null,
|
||||
skillEnterArmed = false,
|
||||
): boolean {
|
||||
if (key === "Tab") return true;
|
||||
if (key !== "Enter") return false;
|
||||
return trigger === "mention" || (trigger === "skill" && skillEnterArmed);
|
||||
}
|
||||
|
||||
export function isSameAutocompleteSession(
|
||||
left: Pick<MentionState, "trigger" | "marker" | "query" | "textNode" | "atPos" | "endPos"> | null,
|
||||
right: Pick<MentionState, "trigger" | "marker" | "query" | "textNode" | "atPos" | "endPos"> | null,
|
||||
): boolean {
|
||||
if (!left || !right) return false;
|
||||
return left.trigger === right.trigger
|
||||
&& left.marker === right.marker
|
||||
&& left.query === right.query
|
||||
&& left.textNode === right.textNode
|
||||
&& left.atPos === right.atPos
|
||||
&& left.endPos === right.endPos;
|
||||
}
|
||||
|
||||
function autocompleteOptionMatchesLink(option: AutocompleteOption, href: string): boolean {
|
||||
const parsed = parseMentionChipHref(href);
|
||||
if (!parsed) return false;
|
||||
|
||||
if (option.kind === "skill") {
|
||||
return parsed.kind === "skill" && parsed.skillId === option.skillId;
|
||||
}
|
||||
|
||||
if (option.kind === "project" && option.projectId) {
|
||||
return parsed.kind === "project" && parsed.projectId === option.projectId;
|
||||
}
|
||||
|
||||
const agentId = option.agentId ?? option.id.replace(/^agent:/, "");
|
||||
return parsed.kind === "agent" && parsed.agentId === agentId;
|
||||
}
|
||||
|
||||
export function findClosestAutocompleteAnchor(
|
||||
editable: HTMLElement,
|
||||
option: AutocompleteOption,
|
||||
origin?: Pick<MentionState, "left" | "top"> | null,
|
||||
): HTMLAnchorElement | null {
|
||||
const matchingMentions = Array.from(editable.querySelectorAll("a"))
|
||||
.filter((node): node is HTMLAnchorElement => node instanceof HTMLAnchorElement)
|
||||
.filter((link) => autocompleteOptionMatchesLink(option, link.getAttribute("href") ?? ""));
|
||||
|
||||
if (matchingMentions.length === 0) return null;
|
||||
if (!origin) return matchingMentions[0] ?? null;
|
||||
|
||||
const containerRect = editable.getBoundingClientRect();
|
||||
return matchingMentions.sort((a, b) => {
|
||||
const rectA = a.getBoundingClientRect();
|
||||
const rectB = b.getBoundingClientRect();
|
||||
const leftA = rectA.left - containerRect.left;
|
||||
const topA = rectA.top - containerRect.top;
|
||||
const leftB = rectB.left - containerRect.left;
|
||||
const topB = rectB.top - containerRect.top;
|
||||
const distA = Math.hypot(leftA - origin.left, topA - origin.top);
|
||||
const distB = Math.hypot(leftB - origin.left, topB - origin.top);
|
||||
return distA - distB;
|
||||
})[0] ?? null;
|
||||
}
|
||||
|
||||
export function placeCaretAfterMentionAnchor(target: HTMLAnchorElement): boolean {
|
||||
const selection = window.getSelection();
|
||||
if (!selection) return false;
|
||||
|
||||
const range = document.createRange();
|
||||
const nextSibling = target.nextSibling;
|
||||
if (nextSibling?.nodeType === Node.TEXT_NODE) {
|
||||
const text = nextSibling.textContent ?? "";
|
||||
if (text.startsWith(" ")) {
|
||||
range.setStart(nextSibling, 1);
|
||||
range.collapse(true);
|
||||
selection.removeAllRanges();
|
||||
selection.addRange(range);
|
||||
return true;
|
||||
}
|
||||
if (text.length > 0) {
|
||||
range.setStart(nextSibling, 0);
|
||||
range.collapse(true);
|
||||
selection.removeAllRanges();
|
||||
selection.addRange(range);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
range.setStartAfter(target);
|
||||
range.collapse(true);
|
||||
selection.removeAllRanges();
|
||||
selection.addRange(range);
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Replace the active autocomplete token in the markdown string with the selected token. */
|
||||
function applyMention(markdown: string, state: MentionState, option: AutocompleteOption): string {
|
||||
const search = `${state.marker}${state.query}`;
|
||||
|
|
@ -346,6 +442,7 @@ export const MarkdownEditor = forwardRef<MarkdownEditorRef, MarkdownEditorProps>
|
|||
const [mentionState, setMentionState] = useState<MentionState | null>(null);
|
||||
const mentionStateRef = useRef<MentionState | null>(null);
|
||||
const [mentionIndex, setMentionIndex] = useState(0);
|
||||
const skillEnterArmedRef = useRef(false);
|
||||
const mentionActive = mentionState !== null && (
|
||||
(mentionState.trigger === "mention" && Boolean(mentions?.length))
|
||||
|| (mentionState.trigger === "skill" && slashCommands.length > 0)
|
||||
|
|
@ -509,6 +606,7 @@ export const MarkdownEditor = forwardRef<MarkdownEditorRef, MarkdownEditorProps>
|
|||
const checkMention = useCallback(() => {
|
||||
if (!containerRef.current || isSelectionInsideCodeLikeElement(containerRef.current)) {
|
||||
mentionStateRef.current = null;
|
||||
skillEnterArmedRef.current = false;
|
||||
setMentionState(null);
|
||||
return;
|
||||
}
|
||||
|
|
@ -519,6 +617,7 @@ export const MarkdownEditor = forwardRef<MarkdownEditorRef, MarkdownEditorProps>
|
|||
&& (!mentions || mentions.length === 0)
|
||||
) {
|
||||
mentionStateRef.current = null;
|
||||
skillEnterArmedRef.current = false;
|
||||
setMentionState(null);
|
||||
return;
|
||||
}
|
||||
|
|
@ -528,16 +627,18 @@ export const MarkdownEditor = forwardRef<MarkdownEditorRef, MarkdownEditorProps>
|
|||
&& slashCommands.length === 0
|
||||
) {
|
||||
mentionStateRef.current = null;
|
||||
skillEnterArmedRef.current = false;
|
||||
setMentionState(null);
|
||||
return;
|
||||
}
|
||||
const previous = mentionStateRef.current;
|
||||
const sameSession = isSameAutocompleteSession(previous, result);
|
||||
mentionStateRef.current = result;
|
||||
if (result) {
|
||||
setMentionState(result);
|
||||
if (!sameSession) {
|
||||
skillEnterArmedRef.current = false;
|
||||
setMentionIndex(0);
|
||||
} else {
|
||||
setMentionState(null);
|
||||
}
|
||||
setMentionState(result);
|
||||
}, [mentions, slashCommands.length]);
|
||||
|
||||
useEffect(() => {
|
||||
|
|
@ -548,21 +649,11 @@ export const MarkdownEditor = forwardRef<MarkdownEditorRef, MarkdownEditorProps>
|
|||
// also fires after typing (e.g. space to dismiss).
|
||||
const onInput = () => requestAnimationFrame(checkMention);
|
||||
|
||||
let selRafId: number | null = null;
|
||||
const onSelectionChange = () => {
|
||||
if (selRafId !== null) return;
|
||||
selRafId = requestAnimationFrame(() => {
|
||||
selRafId = null;
|
||||
checkMention();
|
||||
});
|
||||
};
|
||||
|
||||
document.addEventListener("selectionchange", onSelectionChange);
|
||||
document.addEventListener("selectionchange", checkMention);
|
||||
el?.addEventListener("input", onInput, true);
|
||||
return () => {
|
||||
document.removeEventListener("selectionchange", onSelectionChange);
|
||||
document.removeEventListener("selectionchange", checkMention);
|
||||
el?.removeEventListener("input", onInput, true);
|
||||
if (selRafId !== null) cancelAnimationFrame(selRafId);
|
||||
};
|
||||
}, [checkMention, mentions, slashCommands.length]);
|
||||
|
||||
|
|
@ -589,24 +680,16 @@ export const MarkdownEditor = forwardRef<MarkdownEditorRef, MarkdownEditorProps>
|
|||
const editable = containerRef.current?.querySelector('[contenteditable="true"]');
|
||||
if (!editable) return;
|
||||
decorateProjectMentions();
|
||||
let rafId: number | null = null;
|
||||
const observer = new MutationObserver(() => {
|
||||
if (rafId !== null) return;
|
||||
rafId = requestAnimationFrame(() => {
|
||||
rafId = null;
|
||||
decorateProjectMentions();
|
||||
});
|
||||
decorateProjectMentions();
|
||||
});
|
||||
observer.observe(editable, {
|
||||
subtree: true,
|
||||
childList: true,
|
||||
characterData: true,
|
||||
});
|
||||
return () => {
|
||||
observer.disconnect();
|
||||
if (rafId !== null) cancelAnimationFrame(rafId);
|
||||
};
|
||||
}, [decorateProjectMentions]);
|
||||
return () => observer.disconnect();
|
||||
}, [decorateProjectMentions, value]);
|
||||
|
||||
const selectMention = useCallback(
|
||||
(option: AutocompleteOption) => {
|
||||
|
|
@ -623,65 +706,28 @@ export const MarkdownEditor = forwardRef<MarkdownEditorRef, MarkdownEditorProps>
|
|||
onChange(next);
|
||||
}
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
requestAnimationFrame(() => {
|
||||
const editable = containerRef.current?.querySelector('[contenteditable="true"]');
|
||||
if (!(editable instanceof HTMLElement)) return;
|
||||
decorateProjectMentions();
|
||||
editable.focus();
|
||||
const restoreSelection = (attemptsRemaining: number) => {
|
||||
const editable = containerRef.current?.querySelector('[contenteditable="true"]');
|
||||
if (!(editable instanceof HTMLElement)) return;
|
||||
|
||||
const mentionHref = option.kind === "skill"
|
||||
? option.href
|
||||
: option.kind === "project" && option.projectId
|
||||
? buildProjectMentionHref(option.projectId, option.projectColor ?? null)
|
||||
: buildAgentMentionHref(
|
||||
option.agentId ?? option.id.replace(/^agent:/, ""),
|
||||
option.agentIcon ?? null,
|
||||
);
|
||||
const expectedLabel = option.kind === "skill" ? `/${option.slug}` : `@${option.name}`;
|
||||
const matchingMentions = Array.from(editable.querySelectorAll("a"))
|
||||
.filter((node): node is HTMLAnchorElement => node instanceof HTMLAnchorElement)
|
||||
.filter((link) => {
|
||||
const href = link.getAttribute("href") ?? "";
|
||||
return href === mentionHref && link.textContent === expectedLabel;
|
||||
});
|
||||
const containerRect = containerRef.current?.getBoundingClientRect();
|
||||
const target = matchingMentions.sort((a, b) => {
|
||||
const rectA = a.getBoundingClientRect();
|
||||
const rectB = b.getBoundingClientRect();
|
||||
const leftA = containerRect ? rectA.left - containerRect.left : rectA.left;
|
||||
const topA = containerRect ? rectA.top - containerRect.top : rectA.top;
|
||||
const leftB = containerRect ? rectB.left - containerRect.left : rectB.left;
|
||||
const topB = containerRect ? rectB.top - containerRect.top : rectB.top;
|
||||
const distA = Math.hypot(leftA - state.left, topA - state.top);
|
||||
const distB = Math.hypot(leftB - state.left, topB - state.top);
|
||||
return distA - distB;
|
||||
})[0] ?? null;
|
||||
if (!target) return;
|
||||
decorateProjectMentions();
|
||||
editable.focus();
|
||||
|
||||
const selection = window.getSelection();
|
||||
if (!selection) return;
|
||||
const range = document.createRange();
|
||||
const nextSibling = target.nextSibling;
|
||||
if (nextSibling?.nodeType === Node.TEXT_NODE) {
|
||||
const text = nextSibling.textContent ?? "";
|
||||
if (text.startsWith(" ")) {
|
||||
range.setStart(nextSibling, 1);
|
||||
range.collapse(true);
|
||||
selection.removeAllRanges();
|
||||
selection.addRange(range);
|
||||
return;
|
||||
}
|
||||
const target = findClosestAutocompleteAnchor(editable, option, state);
|
||||
if (!target) {
|
||||
if (attemptsRemaining > 0) {
|
||||
requestAnimationFrame(() => restoreSelection(attemptsRemaining - 1));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
range.setStartAfter(target);
|
||||
range.collapse(true);
|
||||
selection.removeAllRanges();
|
||||
selection.addRange(range);
|
||||
});
|
||||
});
|
||||
placeCaretAfterMentionAnchor(target);
|
||||
};
|
||||
|
||||
requestAnimationFrame(() => restoreSelection(4));
|
||||
|
||||
mentionStateRef.current = null;
|
||||
skillEnterArmedRef.current = false;
|
||||
setMentionState(null);
|
||||
},
|
||||
[decorateProjectMentions, onChange],
|
||||
|
|
@ -737,6 +783,7 @@ export const MarkdownEditor = forwardRef<MarkdownEditorRef, MarkdownEditorProps>
|
|||
if (mentionActive) {
|
||||
if (e.key === " " && mentionStateRef.current?.trigger === "skill") {
|
||||
mentionStateRef.current = null;
|
||||
skillEnterArmedRef.current = false;
|
||||
setMentionState(null);
|
||||
return;
|
||||
}
|
||||
|
|
@ -745,6 +792,7 @@ export const MarkdownEditor = forwardRef<MarkdownEditorRef, MarkdownEditorProps>
|
|||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
mentionStateRef.current = null;
|
||||
skillEnterArmedRef.current = false;
|
||||
setMentionState(null);
|
||||
return;
|
||||
}
|
||||
|
|
@ -753,16 +801,24 @@ export const MarkdownEditor = forwardRef<MarkdownEditorRef, MarkdownEditorProps>
|
|||
if (e.key === "ArrowDown") {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
skillEnterArmedRef.current = mentionStateRef.current?.trigger === "skill";
|
||||
setMentionIndex((prev) => Math.min(prev + 1, filteredMentions.length - 1));
|
||||
return;
|
||||
}
|
||||
if (e.key === "ArrowUp") {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
skillEnterArmedRef.current = mentionStateRef.current?.trigger === "skill";
|
||||
setMentionIndex((prev) => Math.max(prev - 1, 0));
|
||||
return;
|
||||
}
|
||||
if (e.key === "Enter" || e.key === "Tab") {
|
||||
if (
|
||||
shouldAcceptAutocompleteKey(
|
||||
e.key,
|
||||
mentionStateRef.current?.trigger ?? null,
|
||||
skillEnterArmedRef.current,
|
||||
)
|
||||
) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
selectMention(filteredMentions[mentionIndex]);
|
||||
|
|
@ -865,7 +921,12 @@ export const MarkdownEditor = forwardRef<MarkdownEditorRef, MarkdownEditorProps>
|
|||
e.preventDefault(); // prevent blur
|
||||
selectMention(option);
|
||||
}}
|
||||
onMouseEnter={() => setMentionIndex(i)}
|
||||
onMouseEnter={() => {
|
||||
if (mentionStateRef.current?.trigger === "skill") {
|
||||
skillEnterArmedRef.current = true;
|
||||
}
|
||||
setMentionIndex(i);
|
||||
}}
|
||||
>
|
||||
{option.kind === "skill" ? (
|
||||
<Boxes className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue