Files
windmill/frontend/src/lib/components/graph/renderers/nodes/NoteNode.svelte
T
Diego ImbertandClaude Opus 5 a0295b20c4 fix(frontend): render ordered lists in markdown descriptions (#10973)
* fix(frontend): render ordered lists in markdown descriptions

`GfmMarkdown` defaulted to `prose-xs`, which Tailwind Typography does not
define — the class only ever matched four hand-rolled rules in app.css, all
scoped to `ul`. Every surface on that default (script and flow descriptions,
flow-graph notes, markdown job results) therefore rendered `<ol>` with
Preflight's `list-style: none` and no typography at all: no numbers, no
heading or paragraph rhythm.

Route the default through the shared `markdownProse` stacks instead, and cut
the app.css list rules down to the dash glyph so ordered and unordered lists
share Tailwind Typography's indentation and rhythm.

Fixes #10971

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S6G5gDXJnm6uqch4uCPkPE

* fix(frontend): address review nits on the markdown prose fix

- default `GfmMarkdown` to the `sm` stack rather than `xs`: the AI-agent tool
  Message pane takes the default and has no ancestor font size, so `xs` left it
  smaller than its own label. The group note, whose wrapper is `text-2xs`, opts
  down explicitly.
- regenerate `static/tailwind_full.css`, which raw apps are served and which
  still carried the deleted list rules.
- correct the marker-color rationale: the typography config already maps markers
  to tertiary, so the rule steps them up rather than rescuing them.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S6G5gDXJnm6uqch4uCPkPE

* fix(frontend): make the note color override an arbitrary value

`text-inherit` is not generated: this config replaces the Tailwind color palette
outright and defines no `inherit` key, so `[&_*]:!text-inherit` compiled to
nothing and notes still rendered in the prose stack's `text-primary`. Verified in
the browser: a yellow note's list items now compute to `text-yellow-900`, matching
the wrapper and the edit-mode textarea, in both themes.

Also drop the `static/tailwind_full.css` regeneration. That file was generated with
tailwind 3.4.1 against a config predating the typography theme overrides; rebuilding
it today sweeps in 250KB of unrelated churn and would flip every raw app's `.prose`
palette from stock gray to Windmill tokens. Its staleness predates this PR and is
its own change.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S6G5gDXJnm6uqch4uCPkPE

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-05 10:39:44 +00:00

371 lines
10 KiB
Svelte

<script lang="ts">
import { NodeResizer, ViewportPortal, useSvelteFlow } from '@xyflow/svelte'
import { X, Lock, LockOpen } from 'lucide-svelte'
import { twMerge } from 'tailwind-merge'
import GfmMarkdown from '$lib/components/GfmMarkdown.svelte'
import { fade } from 'svelte/transition'
import NoteColorPicker from '../../NoteColorPicker.svelte'
import {
NoteColor,
NOTE_COLORS,
NOTE_TEXT_COLOR_OVERRIDE,
DEFAULT_NOTE_COLOR,
MIN_NOTE_WIDTH,
MIN_NOTE_HEIGHT
} from '../../noteColors'
import { Button } from '$lib/components/common'
import { getNoteEditorContext } from '../../noteEditor.svelte'
import { getGraphContext } from '../../graphContext'
import { clickOutside } from '$lib/utils'
import { tick, untrack } from 'svelte'
interface Props {
data: {
noteId: string
text: string
color: NoteColor
locked?: boolean
isGroupNote?: boolean
editMode?: boolean
// Callback for layout calculations (needed in both edit and view modes)
onTextHeightChange?: (height: number) => void
}
dragging?: boolean
}
let { data, dragging = false }: Props = $props()
// Get SvelteFlow utilities for accessing node position
const { getNode } = useSvelteFlow()
// Get NoteEditor context for edit mode
const noteEditorContext = getNoteEditorContext()
const isEditModeAvailable = $derived(!!noteEditorContext?.noteEditor && data.editMode)
// Get graph context for note selection
const graphContext = getGraphContext()
const noteManager = graphContext?.noteManager
const selected = $derived(noteManager?.isNoteSelected(data.noteId) ?? false)
// Get the current node with position data
const currentNode = $derived(getNode(data.noteId))
let textareaElement: HTMLTextAreaElement | undefined = $state(undefined)
let editMode = $state(false)
let hovering = $state(false)
let textContent = $state(untrack(() => data).text ?? '')
let containerHeight = $state(0)
// Use data props directly - they're kept in sync by NoteManager observer
const color = $derived(data.color ?? DEFAULT_NOTE_COLOR)
const locked = $derived(data.locked ?? false)
const textForDisplay = $derived(data.text ?? '')
function handleTextSave() {
// Only update when done editing
if (isEditModeAvailable && noteEditorContext?.noteEditor) {
noteEditorContext.noteEditor.updateText(data.noteId, textContent)
}
}
function handleDelete(event?: Event) {
event?.preventDefault?.()
event?.stopPropagation?.()
if (isEditModeAvailable && noteEditorContext?.noteEditor) {
noteEditorContext.noteEditor.deleteNote(data.noteId)
}
}
function handleColorChange(color: NoteColor) {
if (isEditModeAvailable && noteEditorContext?.noteEditor) {
noteEditorContext.noteEditor.updateColor(data.noteId, color)
}
}
function handleLockToggle(event?: Event) {
event?.preventDefault?.()
event?.stopPropagation?.()
if (isEditModeAvailable && noteEditorContext?.noteEditor) {
noteEditorContext.noteEditor.updateLock(data.noteId, !locked)
}
}
// Get color configuration for current color
const colorConfig = $derived(NOTE_COLORS[color])
function handleDoubleClick(event: Event) {
event.preventDefault()
event.stopPropagation()
// Don't allow editing if note is locked or edit mode is not available
if (locked || !isEditModeAvailable) {
return
}
editMode = true
// Focus the textarea after a short delay to ensure it's rendered
tick().then(() => {
textareaElement?.focus()
})
}
function handleMouseEnter() {
hovering = true
}
function handleMouseLeave() {
hovering = false
}
// Exit edit mode when note is deselected
$effect(() => {
if (!selected && editMode) {
editMode = false
}
})
let colorPickerIsOpen = $state(false)
function handleNoteClick(event: MouseEvent) {
// Only handle selection if not in edit mode and not dragging
if (!editMode && !dragging && noteManager) {
event.stopPropagation()
noteManager.selectNote(data.noteId)
}
}
function handleNoteKeydown(event: KeyboardEvent) {
// Handle Enter or Space key for selection (accessibility)
if ((event.key === 'Enter' || event.key === ' ') && !editMode && !dragging && noteManager) {
event.preventDefault()
event.stopPropagation()
noteManager.selectNote(data.noteId)
}
}
function handleTextareaKeydown(event: KeyboardEvent) {
// Handle Escape key to exit edit mode
if (event.key === 'Escape') {
event.preventDefault()
event.stopPropagation()
handleTextSave() // Save changes before exiting
editMode = false
textareaElement?.blur() // Remove focus from textarea
}
}
</script>
<!-- Snippet for action buttons to avoid code duplication -->
{#snippet actionButtons()}
<div
class={twMerge(
'hidden group-hover:flex flex-row gap-2 h-fit',
hovering || editMode || colorPickerIsOpen || selected ? 'flex' : ''
)}
>
<!-- Lock/Unlock button -->
<Button
variant="subtle"
unifiedSize="xs"
title={locked ? 'Unlock note' : 'Lock note'}
aria-label={locked ? 'Unlock note' : 'Lock note'}
startIcon={{ icon: locked ? Lock : LockOpen }}
onClick={handleLockToggle}
iconOnly
/>
<!-- Color picker -->
{#if !locked}
<NoteColorPicker
selectedColor={color}
onColorChange={handleColorChange}
bind:isOpen={colorPickerIsOpen}
/>
{/if}
<!-- Delete button -->
{#if !locked}
<Button
variant="subtle"
unifiedSize="xs"
title="Delete note"
aria-label="Delete note"
startIcon={{ icon: X }}
onClick={handleDelete}
iconOnly
destructive
/>
{/if}
</div>
{/snippet}
<div
class={twMerge(
'relative w-full h-full rounded-md group hover:outline outline-1',
colorConfig.background,
colorConfig.text,
colorConfig.outlineHover,
selected ? 'outline' : '',
selected ? colorConfig.outline : '',
editMode ? 'outline-0' : ''
)}
onclick={handleNoteClick}
onkeydown={handleNoteKeydown}
onpointerup={() => {
dragging = false
}}
ondragstart={() => {
dragging = true
}}
ondragend={() => {
dragging = false
}}
onmouseenter={handleMouseEnter}
onmouseleave={handleMouseLeave}
role="button"
tabindex={editMode ? -1 : 0}
ondblclick={handleDoubleClick}
use:clickOutside={{
onClickOutside: () => {
noteManager?.deselectNote(data.noteId)
}
}}
>
<!-- Hover help text -->
{#if hovering || selected}
{#if !editMode && isEditModeAvailable}
<div
in:fade={{ duration: 200 }}
class="absolute -top-5 h-5 left-0 text-2xs text-secondary rounded-md z-10 transition-opacity duration-300"
>
{locked
? 'Note is locked'
: isEditModeAvailable
? 'Double click to edit'
: 'View only mode'}
</div>
{:else if !locked && isEditModeAvailable}
<div
in:fade={{ duration: 200 }}
class="absolute -top-5 h-5 left-0 text-2xs text-secondary rounded-md z-10 transition-opacity duration-300"
>GH Markdown</div
>
{/if}
{/if}
<!-- Note content -->
<div
class={twMerge(
'w-full rounded-md ',
data.isGroupNote ? 'min-h-[60px] max-h-[400px]' : 'h-full'
)}
>
{#if editMode}
<!-- Edit mode: show textarea -->
<textarea
bind:this={textareaElement}
bind:value={textContent}
class={twMerge(
'windmillapp w-full shadow-none resize-none text-xs overflow-y-auto border-none rounded-md bg-transparent transition-colors p-4 nodrag nowheel',
colorConfig.text
)}
placeholder="Double click to edit me"
onblur={handleTextSave}
onkeydown={handleTextareaKeydown}
spellcheck="false"
style:height={data.isGroupNote ? `${containerHeight > 0 ? containerHeight : 60}px` : '100%'}
></textarea>
{:else}
<!-- Render mode: show markdown or empty state -->
<div
class={twMerge(
'w-full h-fit overflow-auto cursor-pointer flex items-start justify-start rounded-md p-4'
)}
bind:clientHeight={
() => containerHeight,
(v) => {
if (v > 0 && v !== containerHeight) {
data.onTextHeightChange?.(v)
}
containerHeight = v
}
}
>
{#if textForDisplay}
<div
class={twMerge(
'w-full text-xs rounded-md break-words overflow-hidden',
colorConfig.text,
NOTE_TEXT_COLOR_OVERRIDE
)}
>
<GfmMarkdown md={textForDisplay} noPadding />
</div>
{:else}
<div class={twMerge('text-xs italic opacity-60', colorConfig.text)}>
Double click to edit me
</div>
{/if}
</div>
{/if}
</div>
<!-- Node resizer - only visible when selected and not locked and edit mode is available -->
{#if !locked && isEditModeAvailable}
<NodeResizer
isVisible={selected && !dragging && !data.isGroupNote}
minWidth={MIN_NOTE_WIDTH}
minHeight={MIN_NOTE_HEIGHT}
lineClass="!border-4 !border-transparent !rounded-md"
handleClass="!bg-transparent !w-4 !h-4 !border-none !rounded-md"
onResizeEnd={(_, params) => {
// Update note size when resizing ends
if (params.width !== undefined && params.height !== undefined) {
const size = { width: params.width, height: params.height }
if (isEditModeAvailable && noteEditorContext?.noteEditor) {
// Use NoteEditor context in edit mode
noteEditorContext.noteEditor.updateSize(data.noteId, size)
}
}
}}
/>
{/if}
<!-- Action buttons - conditional rendering based on note type -->
{#if isEditModeAvailable}
{#if data.isGroupNote && currentNode?.position}
<!-- Group notes: Use ViewportPortal to render above graph edges -->
<ViewportPortal target="front">
<div
class="absolute p-1 w-24 h-8 group flex justify-end"
style:transform="translate({currentNode.position.x +
(currentNode.measured?.width ?? MIN_NOTE_WIDTH) -
100}px , {currentNode.position.y - 30}px)"
style:pointer-events="auto"
style:z-index="1000"
>
{@render actionButtons()}
</div>
</ViewportPortal>
{:else}
<!-- Standalone notes: Use normal absolute positioning -->
<div class="absolute -top-8 -right-2.5 p-2 w-32 h-12 group flex justify-end">
{@render actionButtons()}
</div>
{/if}
{/if}
</div>
<style>
textarea::placeholder {
color: #6b7280;
opacity: 0.7;
}
/* Remove default textarea styling */
textarea {
font-family: inherit;
line-height: 1.4;
}
</style>