diff --git a/docs/development/canvas-text-tool.md b/docs/development/canvas-text-tool.md new file mode 100644 index 00000000000..1d9b71109c2 --- /dev/null +++ b/docs/development/canvas-text-tool.md @@ -0,0 +1,35 @@ +# Canvas Text Tool + +## Overview + +The canvas text workflow is split between a Konva module that owns tool state and a React overlay that handles text entry. + +- `invokeai/frontend/web/src/features/controlLayers/konva/CanvasTool/CanvasTextToolModule.ts` + - Owns the tool, cursor preview, and text session state (including the cursor "T" marker). + - Manages dynamic cursor contrast, starts sessions on pointer down, and commits sessions by rasterizing the active text block into a new raster layer. +- `invokeai/frontend/web/src/features/controlLayers/components/Text/CanvasTextOverlay.tsx` + - Renders the on-canvas editor as a `contentEditable` overlay positioned in canvas space. + - Syncs keyboard input, suppresses app hotkeys, and forwards commits/cancels to the Konva module. +- `invokeai/frontend/web/src/features/controlLayers/components/Text/TextToolOptions.tsx` + - Provides the font dropdown, size slider/input, formatting toggles, and alignment buttons that appear when the Text tool is active. + +## Rasterization pipeline + +`renderTextToCanvas()` (`invokeai/frontend/web/src/features/controlLayers/text/textRenderer.ts`) converts the editor contents into a transparent canvas. The Text tool module configures the renderer with the active font stack, weight, styling flags, alignment, and the active canvas color. The resulting canvas is encoded to a PNG data URL and stored in a new raster layer (`image` object) with a transparent background. + +Layer placement preserves the original click location: + +- The session stores the anchor coordinate (where the user clicked) and current alignment. +- `calculateLayerPosition()` calculates the top-left position for the raster layer after applying the configured padding and alignment offsets. +- New layers are inserted directly above the currently-selected raster layer (when present) and selected automatically. + +## Font stacks + +Font definitions live in `invokeai/frontend/web/src/features/controlLayers/text/textConstants.ts` as ten deterministic stacks (sans, serif, mono, rounded, script, humanist, slab serif, display, narrow, UI serif). Each stack lists system-safe fallbacks so the editor can choose the first available font per platform. + +To add or adjust fonts: + +1. Update `TEXT_FONT_STACKS` with the new `id`, `label`, and CSS `font-family` stack. +2. If you add a new stack, extend the `TEXT_FONT_IDS` tuple and update the `canvasTextSlice` schema default (`TEXT_DEFAULT_FONT_ID`). +3. Provide translation strings for any new labels in `public/locales/*`. +4. The editor and renderer will automatically pick up the new stack via `getFontStackById()`. diff --git a/invokeai/frontend/web/public/locales/en.json b/invokeai/frontend/web/public/locales/en.json index 881d7253270..4323cb1835e 100644 --- a/invokeai/frontend/web/public/locales/en.json +++ b/invokeai/frontend/web/public/locales/en.json @@ -2371,7 +2371,19 @@ "bbox": "Bbox", "move": "Move", "view": "View", - "colorPicker": "Color Picker" + "colorPicker": "Color Picker", + "text": "Text" + }, + "text": { + "font": "Font", + "size": "Size", + "bold": "Bold", + "italic": "Italic", + "underline": "Underline", + "strikethrough": "Strikethrough", + "alignLeft": "Align Left", + "alignCenter": "Align Center", + "alignRight": "Align Right" }, "filter": { "filter": "Filter", diff --git a/invokeai/frontend/web/src/app/store/store.ts b/invokeai/frontend/web/src/app/store/store.ts index 3babf2404ae..232ab091470 100644 --- a/invokeai/frontend/web/src/app/store/store.ts +++ b/invokeai/frontend/web/src/app/store/store.ts @@ -23,6 +23,7 @@ import { changeBoardModalSliceConfig } from 'features/changeBoardModal/store/sli import { canvasSettingsSliceConfig } from 'features/controlLayers/store/canvasSettingsSlice'; import { canvasSliceConfig } from 'features/controlLayers/store/canvasSlice'; import { canvasSessionSliceConfig } from 'features/controlLayers/store/canvasStagingAreaSlice'; +import { canvasTextSliceConfig } from 'features/controlLayers/store/canvasTextSlice'; import { lorasSliceConfig } from 'features/controlLayers/store/lorasSlice'; import { paramsSliceConfig } from 'features/controlLayers/store/paramsSlice'; import { refImagesSliceConfig } from 'features/controlLayers/store/refImagesSlice'; @@ -62,6 +63,7 @@ const log = logger('system'); const SLICE_CONFIGS = { [canvasSessionSliceConfig.slice.reducerPath]: canvasSessionSliceConfig, [canvasSettingsSliceConfig.slice.reducerPath]: canvasSettingsSliceConfig, + [canvasTextSliceConfig.slice.reducerPath]: canvasTextSliceConfig, [canvasSliceConfig.slice.reducerPath]: canvasSliceConfig, [changeBoardModalSliceConfig.slice.reducerPath]: changeBoardModalSliceConfig, [dynamicPromptsSliceConfig.slice.reducerPath]: dynamicPromptsSliceConfig, @@ -87,6 +89,7 @@ const ALL_REDUCERS = { [api.reducerPath]: api.reducer, [canvasSessionSliceConfig.slice.reducerPath]: canvasSessionSliceConfig.slice.reducer, [canvasSettingsSliceConfig.slice.reducerPath]: canvasSettingsSliceConfig.slice.reducer, + [canvasTextSliceConfig.slice.reducerPath]: canvasTextSliceConfig.slice.reducer, // Undoable! [canvasSliceConfig.slice.reducerPath]: undoable( canvasSliceConfig.slice.reducer, diff --git a/invokeai/frontend/web/src/features/controlLayers/components/Text/CanvasTextOverlay.tsx b/invokeai/frontend/web/src/features/controlLayers/components/Text/CanvasTextOverlay.tsx new file mode 100644 index 00000000000..3231531724f --- /dev/null +++ b/invokeai/frontend/web/src/features/controlLayers/components/Text/CanvasTextOverlay.tsx @@ -0,0 +1,327 @@ +import { Box, Flex } from '@invoke-ai/ui-library'; +import { useStore } from '@nanostores/react'; +import { useAppSelector } from 'app/store/storeHooks'; +import { rgbaColorToString } from 'common/util/colorCodeTransformers'; +import { useCanvasManager } from 'features/controlLayers/contexts/CanvasManagerProviderGate'; +import { selectCanvasSettingsSlice } from 'features/controlLayers/store/canvasSettingsSlice'; +import type { CanvasTextSettingsState } from 'features/controlLayers/store/canvasTextSlice'; +import { selectCanvasTextSlice } from 'features/controlLayers/store/canvasTextSlice'; +import { getFontStackById, TEXT_RASTER_PADDING } from 'features/controlLayers/text/textConstants'; +import { isAllowedTextShortcut } from 'features/controlLayers/text/textHotkeys'; +import { hasVisibleGlyphs, measureTextContent, type TextMeasureConfig } from 'features/controlLayers/text/textRenderer'; +import { + type ClipboardEvent as ReactClipboardEvent, + type KeyboardEvent as ReactKeyboardEvent, + memo, + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from 'react'; + +export const CanvasTextOverlay = memo(() => { + const canvasManager = useCanvasManager(); + const session = useStore(canvasManager.tool.tools.text.$session); + const stageAttrs = useStore(canvasManager.stage.$stageAttrs); + + if (!session) { + return null; + } + + return ( + + + + + + ); +}); + +CanvasTextOverlay.displayName = 'CanvasTextOverlay'; + +const buildMeasureConfig = (text: string, settings: CanvasTextSettingsState): TextMeasureConfig => { + const fontStyle: TextMeasureConfig['fontStyle'] = settings.italic ? 'italic' : 'normal'; + return { + text, + fontSize: settings.fontSize, + fontFamily: getFontStackById(settings.fontId), + fontWeight: settings.bold ? 700 : 400, + fontStyle, + lineHeight: settings.lineHeight, + }; +}; + +const TextEditor = ({ + sessionId, + anchor, + initialText, +}: { + sessionId: string; + anchor: { x: number; y: number }; + initialText: string; +}) => { + const canvasManager = useCanvasManager(); + const textSettings = useAppSelector(selectCanvasTextSlice); + const canvasSettings = useAppSelector(selectCanvasSettingsSlice); + const editorRef = useRef(null); + const lastSessionIdRef = useRef(null); + const lastFocusedSessionIdRef = useRef(null); + const focusRafIdRef = useRef(null); + const [isComposing, setIsComposing] = useState(false); + const [textValue, setTextValue] = useState(initialText); + const [contentMetrics, setContentMetrics] = useState(() => + measureTextContent(buildMeasureConfig(initialText, textSettings)) + ); + const [isEmpty, setIsEmpty] = useState(() => !hasVisibleGlyphs(initialText)); + + const focusEditor = useCallback(() => { + const node = editorRef.current; + if (!node) { + return; + } + node.focus({ preventScroll: true }); + const selection = window.getSelection(); + if (!selection) { + return; + } + const range = document.createRange(); + range.selectNodeContents(node); + range.collapse(false); + selection.removeAllRanges(); + selection.addRange(range); + }, []); + + const setEditorRef = useCallback((node: HTMLDivElement | null) => { + editorRef.current = node; + }, []); + + useEffect(() => { + const node = editorRef.current; + if (!node) { + return; + } + const isNewSession = lastSessionIdRef.current !== sessionId; + if (isNewSession) { + lastSessionIdRef.current = sessionId; + lastFocusedSessionIdRef.current = null; + node.textContent = initialText; + const syncedText = (node.innerText ?? '').replace(/\r/g, ''); + setIsEmpty(!hasVisibleGlyphs(syncedText)); + setTextValue(syncedText); + setContentMetrics(measureTextContent(buildMeasureConfig(syncedText, textSettings))); + canvasManager.tool.tools.text.updateSessionText(sessionId, syncedText); + } + if (lastFocusedSessionIdRef.current !== sessionId) { + if (focusRafIdRef.current !== null) { + cancelAnimationFrame(focusRafIdRef.current); + } + focusRafIdRef.current = requestAnimationFrame(() => { + canvasManager.tool.tools.text.markSessionEditing(sessionId); + focusEditor(); + lastFocusedSessionIdRef.current = sessionId; + focusRafIdRef.current = null; + }); + } + return () => { + if (focusRafIdRef.current !== null) { + cancelAnimationFrame(focusRafIdRef.current); + focusRafIdRef.current = null; + } + }; + }, [canvasManager.tool.tools.text, focusEditor, initialText, sessionId, textSettings]); + + useEffect(() => { + setContentMetrics(measureTextContent(buildMeasureConfig(textValue, textSettings))); + }, [textSettings, textValue]); + + useEffect(() => { + const shouldIgnorePointerDown = (event: PointerEvent) => { + const target = event.target as HTMLElement | null; + if (!target) { + return false; + } + const path = event.composedPath?.() ?? []; + for (const node of path) { + if (!(node instanceof HTMLElement)) { + continue; + } + const role = node.getAttribute('role'); + if (role === 'listbox' || role === 'option') { + return true; + } + if (editorRef.current && editorRef.current.contains(node)) { + return true; + } + if (node.dataset?.textToolSafezone === 'true') { + return true; + } + } + return editorRef.current?.contains(target) ?? false; + }; + + const handlePointerDown = (event: PointerEvent) => { + if (shouldIgnorePointerDown(event)) { + return; + } + canvasManager.tool.tools.text.requestCommit(sessionId); + }; + window.addEventListener('pointerdown', handlePointerDown, true); + return () => window.removeEventListener('pointerdown', handlePointerDown, true); + }, [canvasManager.tool.tools.text, sessionId]); + + const handleInput = useCallback(() => { + const value = (editorRef.current?.innerText ?? '').replace(/\r/g, ''); + setIsEmpty(!hasVisibleGlyphs(value)); + setTextValue(value); + setContentMetrics(measureTextContent(buildMeasureConfig(value, textSettings))); + canvasManager.tool.tools.text.updateSessionText(sessionId, value); + }, [canvasManager.tool.tools.text, sessionId, textSettings]); + + const handleKeyDown = useCallback( + (event: ReactKeyboardEvent) => { + const nativeEvent = event.nativeEvent; + if (!isAllowedTextShortcut(nativeEvent)) { + event.stopPropagation(); + nativeEvent.stopPropagation(); + nativeEvent.stopImmediatePropagation?.(); + } + + if (event.key === 'Enter' && !event.shiftKey && !isComposing) { + event.preventDefault(); + canvasManager.tool.tools.text.requestCommit(sessionId); + } + + if (event.key === 'Escape') { + event.preventDefault(); + canvasManager.tool.tools.text.clearSession(); + } + }, + [canvasManager.tool.tools.text, isComposing, sessionId] + ); + + const handlePaste = useCallback((event: ReactClipboardEvent) => { + event.preventDefault(); + const text = event.clipboardData.getData('text/plain'); + document.execCommand('insertText', false, text); + }, []); + + const handleCompositionStart = useCallback(() => setIsComposing(true), []); + const handleCompositionEnd = useCallback(() => setIsComposing(false), []); + + const textContainerData = useMemo(() => { + const padding = TEXT_RASTER_PADDING; + const extraRightPadding = Math.ceil(textSettings.fontSize * 0.26); + const extraLeftPadding = Math.ceil(textSettings.fontSize * 0.12); + let offsetX = -padding - extraLeftPadding; + if (textSettings.alignment === 'center') { + offsetX = -(contentMetrics.contentWidth / 2) - padding - extraLeftPadding; + } else if (textSettings.alignment === 'right') { + offsetX = -contentMetrics.contentWidth - padding - extraLeftPadding; + } + return { + x: anchor.x + offsetX, + y: anchor.y - padding, + padding, + extraLeftPadding, + extraRightPadding, + width: contentMetrics.contentWidth + padding * 2 + extraLeftPadding + extraRightPadding, + height: contentMetrics.contentHeight + padding * 2, + }; + }, [ + anchor.x, + anchor.y, + contentMetrics.contentHeight, + contentMetrics.contentWidth, + textSettings.alignment, + textSettings.fontSize, + ]); + + useEffect(() => { + canvasManager.tool.tools.text.updateSessionPosition(sessionId, { + x: textContainerData.x, + y: textContainerData.y, + }); + }, [canvasManager.tool.tools.text, sessionId, textContainerData]); + + const containerStyle = useMemo(() => { + return { + left: `${textContainerData.x}px`, + top: `${textContainerData.y}px`, + paddingTop: `${textContainerData.padding}px`, + paddingBottom: `${textContainerData.padding}px`, + paddingLeft: `${textContainerData.padding + textContainerData.extraLeftPadding}px`, + paddingRight: `${textContainerData.padding + textContainerData.extraRightPadding}px`, + width: `${Math.max(textContainerData.width, textSettings.fontSize)}px`, + height: `${Math.max(textContainerData.height, textSettings.fontSize)}px`, + textAlign: textSettings.alignment, + }; + }, [textContainerData, textSettings.alignment, textSettings.fontSize]); + + const textStyle = useMemo(() => { + const color = + canvasSettings.activeColor === 'fgColor' + ? rgbaColorToString(canvasSettings.fgColor) + : rgbaColorToString(canvasSettings.bgColor); + const decorations: string[] = []; + if (textSettings.underline) { + decorations.push('underline'); + } + if (textSettings.strikethrough) { + decorations.push('line-through'); + } + return { + fontFamily: getFontStackById(textSettings.fontId), + fontWeight: textSettings.bold ? 700 : 400, + fontStyle: textSettings.italic ? 'italic' : 'normal', + textDecorationLine: decorations.length ? decorations.join(' ') : 'none', + fontSize: `${textSettings.fontSize}px`, + lineHeight: `${contentMetrics.lineHeightPx}px`, + color, + textAlign: textSettings.alignment, + } as const; + }, [canvasSettings, contentMetrics.lineHeightPx, textSettings]); + + return ( + + + + ); +}; diff --git a/invokeai/frontend/web/src/features/controlLayers/components/Text/TextToolOptions.tsx b/invokeai/frontend/web/src/features/controlLayers/components/Text/TextToolOptions.tsx new file mode 100644 index 00000000000..d756d435fdf --- /dev/null +++ b/invokeai/frontend/web/src/features/controlLayers/components/Text/TextToolOptions.tsx @@ -0,0 +1,309 @@ +import { + Box, + ButtonGroup, + Combobox, + CompositeSlider, + Flex, + IconButton, + NumberInput, + NumberInputField, + Popover, + PopoverAnchor, + PopoverArrow, + PopoverBody, + PopoverContent, + PopoverTrigger, + Portal, + Text, + Tooltip, +} from '@invoke-ai/ui-library'; +import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; +import { + selectTextAlignment, + selectTextFontId, + selectTextFontSize, + textAlignmentChanged, + textBoldToggled, + textFontChanged, + textFontSizeChanged, + textItalicToggled, + textStrikethroughToggled, + textUnderlineToggled, +} from 'features/controlLayers/store/canvasTextSlice'; +import { + resolveAvailableFont, + TEXT_FONT_STACKS, + TEXT_MAX_FONT_SIZE, + TEXT_MIN_FONT_SIZE, + type TextFontId, +} from 'features/controlLayers/text/textConstants'; +import { useCallback, useEffect, useMemo, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { + PiCaretDownBold, + PiTextAlignCenterBold, + PiTextAlignLeftBold, + PiTextAlignRightBold, + PiTextBBold, + PiTextItalicBold, + PiTextStrikethroughBold, + PiTextUnderlineBold, +} from 'react-icons/pi'; + +const formatPx = (value: number | string) => { + if (isNaN(Number(value))) { + return ''; + } + return `${value} px`; +}; + +const formatSliderValue = (value: number) => String(value); + +export const TextToolOptions = () => { + return ( + + + + + + + ); +}; + +const FontSelect = () => { + const { t } = useTranslation(); + const dispatch = useAppDispatch(); + const fontId = useAppSelector(selectTextFontId); + const options = useMemo(() => { + return TEXT_FONT_STACKS.map(({ id, label, stack }) => { + const resolved = resolveAvailableFont(stack); + return { + value: id, + label: `${label} (${resolved})`, + }; + }); + }, []); + const selectedOption = options.find((option) => option.value === fontId) ?? null; + const handleFontChange = useCallback( + (option: { value: string } | null) => { + if (!option) { + return; + } + dispatch(textFontChanged(option.value as TextFontId)); + }, + [dispatch] + ); + + return ( + + + {t('controlLayers.text.font', { defaultValue: 'Font' })} + + + + + + ); +}; + +const FontSizeControl = () => { + const { t } = useTranslation(); + const dispatch = useAppDispatch(); + const fontSize = useAppSelector(selectTextFontSize); + const [localFontSize, setLocalFontSize] = useState(fontSize); + const marks = useMemo( + () => + [5, 50, 100, 200, 300, 400, 500].filter((value) => value >= TEXT_MIN_FONT_SIZE && value <= TEXT_MAX_FONT_SIZE), + [] + ); + const onChangeNumberInput = useCallback( + (valueAsString: string, valueAsNumber: number) => { + setLocalFontSize(valueAsNumber); + if (!isNaN(valueAsNumber)) { + dispatch(textFontSizeChanged(valueAsNumber)); + } + }, + [dispatch] + ); + const onChangeSlider = useCallback( + (value: number) => { + setLocalFontSize(value); + dispatch(textFontSizeChanged(value)); + }, + [dispatch] + ); + const onBlur = useCallback(() => { + if (isNaN(Number(localFontSize))) { + setLocalFontSize(fontSize); + return; + } + dispatch(textFontSizeChanged(localFontSize)); + }, [dispatch, fontSize, localFontSize]); + + useEffect(() => { + setLocalFontSize(fontSize); + }, [fontSize]); + + return ( + + + {t('controlLayers.text.size', { defaultValue: 'Size' })} + + + + + + + + + + } + size="sm" + variant="link" + position="absolute" + insetInlineEnd={0} + h="full" + /> + + + + + + + + + + + + + + + + + ); +}; + +const FormatControls = () => { + const { t } = useTranslation(); + const dispatch = useAppDispatch(); + const isBold = useAppSelector((state) => state.canvasText.bold); + const isItalic = useAppSelector((state) => state.canvasText.italic); + const isUnderline = useAppSelector((state) => state.canvasText.underline); + const isStrikethrough = useAppSelector((state) => state.canvasText.strikethrough); + const handleBoldToggle = useCallback(() => dispatch(textBoldToggled()), [dispatch]); + const handleItalicToggle = useCallback(() => dispatch(textItalicToggled()), [dispatch]); + const handleUnderlineToggle = useCallback(() => dispatch(textUnderlineToggled()), [dispatch]); + const handleStrikethroughToggle = useCallback(() => dispatch(textStrikethroughToggled()), [dispatch]); + + return ( + + + } + size="sm" + /> + + + } + size="sm" + /> + + + } + size="sm" + /> + + + } + size="sm" + /> + + + ); +}; + +const AlignmentControls = () => { + const { t } = useTranslation(); + const dispatch = useAppDispatch(); + const alignment = useAppSelector(selectTextAlignment); + const handleAlignLeft = useCallback(() => dispatch(textAlignmentChanged('left')), [dispatch]); + const handleAlignCenter = useCallback(() => dispatch(textAlignmentChanged('center')), [dispatch]); + const handleAlignRight = useCallback(() => dispatch(textAlignmentChanged('right')), [dispatch]); + + return ( + + + } + size="sm" + /> + + + } + size="sm" + /> + + + } + size="sm" + /> + + + ); +}; diff --git a/invokeai/frontend/web/src/features/controlLayers/components/Tool/PinnedFillColorPickerOverlay.tsx b/invokeai/frontend/web/src/features/controlLayers/components/Tool/PinnedFillColorPickerOverlay.tsx index 92f7320fbc1..38cf2d81b21 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/Tool/PinnedFillColorPickerOverlay.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/Tool/PinnedFillColorPickerOverlay.tsx @@ -51,7 +51,7 @@ export const PinnedFillColorPickerOverlay = memo(() => { } return ( - + { + diff --git a/invokeai/frontend/web/src/features/controlLayers/components/Tool/ToolFillColorPicker.tsx b/invokeai/frontend/web/src/features/controlLayers/components/Tool/ToolFillColorPicker.tsx index 231296eec7b..13bd3b30086 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/Tool/ToolFillColorPicker.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/Tool/ToolFillColorPicker.tsx @@ -101,7 +101,15 @@ export const ToolFillColorPicker = memo(() => { returnFocusOnClose={true} > - + { - + diff --git a/invokeai/frontend/web/src/features/controlLayers/components/Tool/ToolOptionsRowContainer.tsx b/invokeai/frontend/web/src/features/controlLayers/components/Tool/ToolOptionsRowContainer.tsx new file mode 100644 index 00000000000..9d4620ac538 --- /dev/null +++ b/invokeai/frontend/web/src/features/controlLayers/components/Tool/ToolOptionsRowContainer.tsx @@ -0,0 +1,21 @@ +import { Flex, type FlexProps } from '@invoke-ai/ui-library'; +import { forwardRef } from 'react'; + +export const ToolOptionsRowContainer = forwardRef((props, ref) => { + return ( + + ); +}); + +ToolOptionsRowContainer.displayName = 'ToolOptionsRowContainer'; diff --git a/invokeai/frontend/web/src/features/controlLayers/components/Tool/ToolTextButton.tsx b/invokeai/frontend/web/src/features/controlLayers/components/Tool/ToolTextButton.tsx new file mode 100644 index 00000000000..f1cabbc7954 --- /dev/null +++ b/invokeai/frontend/web/src/features/controlLayers/components/Tool/ToolTextButton.tsx @@ -0,0 +1,25 @@ +import { IconButton, Tooltip } from '@invoke-ai/ui-library'; +import { useSelectTool, useToolIsSelected } from 'features/controlLayers/components/Tool/hooks'; +import { memo } from 'react'; +import { useTranslation } from 'react-i18next'; +import { PiTextTBold } from 'react-icons/pi'; + +export const ToolTextButton = memo(() => { + const { t } = useTranslation(); + const isSelected = useToolIsSelected('text'); + const selectText = useSelectTool('text'); + + return ( + + } + colorScheme={isSelected ? 'invokeBlue' : 'base'} + variant="solid" + onClick={selectText} + /> + + ); +}); + +ToolTextButton.displayName = 'ToolTextButton'; diff --git a/invokeai/frontend/web/src/features/controlLayers/components/Tool/ToolWidthPicker.tsx b/invokeai/frontend/web/src/features/controlLayers/components/Tool/ToolWidthPicker.tsx index 3fa270893a3..9c69eedd058 100644 --- a/invokeai/frontend/web/src/features/controlLayers/components/Tool/ToolWidthPicker.tsx +++ b/invokeai/frontend/web/src/features/controlLayers/components/Tool/ToolWidthPicker.tsx @@ -309,7 +309,7 @@ export const ToolWidthPicker = memo(() => { }); return ( - + {componentType === 'slider' && ( { const isBrushSelected = useToolIsSelected('brush'); const isEraserSelected = useToolIsSelected('eraser'); + const isTextSelected = useToolIsSelected('text'); const showToolWithPicker = useMemo(() => { - return isBrushSelected || isEraserSelected; - }, [isBrushSelected, isEraserSelected]); + return !isTextSelected && (isBrushSelected || isEraserSelected); + }, [isBrushSelected, isEraserSelected, isTextSelected]); useCanvasResetLayerHotkey(); useCanvasDeleteLayerHotkey(); @@ -43,10 +46,10 @@ export const CanvasToolbar = memo(() => { return ( - + - {showToolWithPicker && } - + {isTextSelected ? : showToolWithPicker && } + diff --git a/invokeai/frontend/web/src/features/controlLayers/konva/CanvasTool/CanvasTextToolModule.ts b/invokeai/frontend/web/src/features/controlLayers/konva/CanvasTool/CanvasTextToolModule.ts new file mode 100644 index 00000000000..b0748746acb --- /dev/null +++ b/invokeai/frontend/web/src/features/controlLayers/konva/CanvasTool/CanvasTextToolModule.ts @@ -0,0 +1,393 @@ +import type { CanvasManager } from 'features/controlLayers/konva/CanvasManager'; +import { CanvasModuleBase } from 'features/controlLayers/konva/CanvasModuleBase'; +import type { CanvasToolModule } from 'features/controlLayers/konva/CanvasTool/CanvasToolModule'; +import { getPrefixedId } from 'features/controlLayers/konva/util'; +import { type CanvasTextSettingsState, selectCanvasTextSlice } from 'features/controlLayers/store/canvasTextSlice'; +import type { CanvasImageState, Coordinate, RgbaColor, Tool } from 'features/controlLayers/store/types'; +import { getFontStackById, TEXT_RASTER_PADDING } from 'features/controlLayers/text/textConstants'; +import { + buildFontDescriptor, + calculateLayerPosition, + hasVisibleGlyphs, + measureTextContent, + renderTextToCanvas, + type TextMeasureConfig, +} from 'features/controlLayers/text/textRenderer'; +import { type TextSessionStatus, transitionTextSessionStatus } from 'features/controlLayers/text/textSessionMachine'; +import Konva from 'konva'; +import type { KonvaEventObject } from 'konva/lib/Node'; +import { atom } from 'nanostores'; +import type { Logger } from 'roarr'; + +type CanvasTextSessionState = { + id: string; + anchor: Coordinate; + position: Coordinate | null; + status: CanvasTextSessionStatus; + createdAt: number; + text: string; +}; + +type CanvasTextToolModuleConfig = { + CURSOR_MIN_WIDTH_PX: number; +}; + +type CanvasTextSessionStatus = Exclude; + +const coerceSessionStatus = (status: TextSessionStatus): CanvasTextSessionStatus => { + if (status === 'idle') { + return 'pending'; + } + return status; +}; + +const DEFAULT_CONFIG: CanvasTextToolModuleConfig = { + CURSOR_MIN_WIDTH_PX: 1.5, +}; + +export class CanvasTextToolModule extends CanvasModuleBase { + readonly type = 'text_tool'; + readonly id: string; + readonly path: string[]; + readonly parent: CanvasToolModule; + readonly manager: CanvasManager; + readonly log: Logger; + + config: CanvasTextToolModuleConfig = DEFAULT_CONFIG; + + konva: { + group: Konva.Group; + cursor: Konva.Rect; + label: Konva.Text; + }; + + $session = atom(null); + private subscriptions = new Set<() => void>(); + private cursorHeight = 0; + private cursorMetricsKey: string | null = null; + + constructor(parent: CanvasToolModule) { + super(); + this.id = getPrefixedId(this.type); + this.parent = parent; + this.manager = parent.manager; + this.path = this.manager.buildPath(this); + this.log = this.manager.buildLogger(this); + + this.konva = { + group: new Konva.Group({ name: `${this.type}:group`, listening: false }), + cursor: new Konva.Rect({ + name: `${this.type}:cursor`, + width: 1, + height: 10, + listening: false, + perfectDrawEnabled: false, + }), + label: new Konva.Text({ + name: `${this.type}:label`, + text: 'T', + listening: false, + perfectDrawEnabled: false, + }), + }; + + this.konva.group.add(this.konva.cursor); + this.konva.group.add(this.konva.label); + this.konva.label.visible(true); + + this.subscriptions.add( + this.manager.stateApi.createStoreSubscription(selectCanvasTextSlice, () => { + this.render(); + }) + ); + this.subscriptions.add( + this.parent.$cursorPos.listen(() => { + this.render(); + }) + ); + } + + destroy = () => { + this.subscriptions.forEach((unsubscribe) => unsubscribe()); + this.subscriptions.clear(); + this.konva.group.destroy(); + }; + + syncCursorStyle = () => { + const session = this.$session.get(); + if (session?.status === 'editing') { + this.manager.stage.setCursor('default'); + return; + } + this.manager.stage.setCursor('none'); + }; + + render = () => { + const textSettings = this.manager.stateApi.runSelector(selectCanvasTextSlice); + const cursorPos = this.parent.$cursorPos.get(); + const session = this.$session.get(); + + if (this.parent.$tool.get() !== 'text' || !cursorPos || (session && session.status === 'editing')) { + this.setVisibility(false); + return; + } + + this.setVisibility(true); + this.setCursorDimensions(textSettings); + this.setCursorPosition(cursorPos.relative, textSettings); + }; + + private setCursorDimensions = (settings: CanvasTextSettingsState) => { + const onePixel = this.manager.stage.unscale(this.config.CURSOR_MIN_WIDTH_PX); + const cursorWidth = Math.max(onePixel * 2, onePixel); + const metricsKey = `${settings.fontId}|${settings.fontSize}|${settings.bold}|${settings.italic}|${settings.lineHeight}`; + if (this.cursorMetricsKey !== metricsKey) { + const measureConfig: TextMeasureConfig = { + text: 'Mg', + fontSize: settings.fontSize, + fontFamily: getFontStackById(settings.fontId), + fontWeight: settings.bold ? 700 : 400, + fontStyle: settings.italic ? 'italic' : 'normal', + lineHeight: settings.lineHeight, + }; + const metrics = measureTextContent(measureConfig); + this.cursorHeight = Math.max(metrics.lineHeightPx, settings.fontSize) + TEXT_RASTER_PADDING * 2; + this.cursorMetricsKey = metricsKey; + } + const height = this.cursorHeight || settings.fontSize + TEXT_RASTER_PADDING * 2; + this.konva.cursor.setAttrs({ + width: cursorWidth, + height, + }); + this.konva.label.setAttrs({ + fontFamily: getFontStackById('uiSerif'), + fontSize: Math.max(12, height * 0.35), + fontStyle: settings.bold ? '700' : '400', + fill: 'rgba(0, 0, 0, 1)', + stroke: 'rgba(255, 255, 255, 1)', + strokeWidth: Math.max(1, onePixel), + }); + this.konva.cursor.fill('rgba(0, 0, 0, 1)'); + this.konva.cursor.stroke('rgba(255, 255, 255, 1)'); + this.konva.cursor.strokeWidth(onePixel); + }; + + private setCursorPosition = (cursor: Coordinate, _settings: CanvasTextSettingsState) => { + const top = cursor.y - TEXT_RASTER_PADDING; + this.konva.cursor.setAttrs({ + x: cursor.x, + y: top, + }); + const labelFontSize = this.konva.label.fontSize(); + this.konva.label.setAttrs({ + x: cursor.x + this.konva.cursor.width() * 1.5, + y: top + this.konva.cursor.height() - labelFontSize * 0.6, + }); + }; + + private setVisibility = (visible: boolean) => { + this.konva.group.visible(visible); + this.konva.label.visible(visible); + }; + + onStagePointerMove = (e: KonvaEventObject) => { + if (e.target !== this.parent.konva.stage) { + return; + } + const cursorPos = this.parent.$cursorPos.get(); + if (!cursorPos) { + return; + } + }; + + onStagePointerEnter = (e: KonvaEventObject) => { + if (e.target !== this.parent.konva.stage) { + return; + } + const cursorPos = this.parent.$cursorPos.get(); + if (!cursorPos) { + return; + } + }; + + onStagePointerDown = (e: KonvaEventObject) => { + // Only allow left-click/primary pointer to begin typing sessions. + if (e.target !== this.parent.konva.stage || e.evt.button !== 0) { + return; + } + const cursorPos = this.parent.$cursorPos.get(); + if (!cursorPos) { + return; + } + if (this.$session.get()) { + this.commitExistingSession(); + } + this.beginSession(cursorPos.relative); + }; + + beginSession = (anchor: Coordinate) => { + const current = this.$session.get(); + if (current && current.status === 'editing') { + return; + } + const id = getPrefixedId('text_session'); + const status = coerceSessionStatus(transitionTextSessionStatus('idle', 'BEGIN')); + this.$session.set({ + id, + anchor, + position: null, + status, + createdAt: Date.now(), + text: '', + }); + }; + + markSessionEditing = (id: string) => { + const current = this.$session.get(); + if (!current || current.id !== id) { + return; + } + const nextStatus = coerceSessionStatus(transitionTextSessionStatus(current.status, 'EDIT')); + this.$session.set({ + ...current, + status: nextStatus, + }); + }; + + clearSession = () => { + this.$session.set(null); + }; + + updateSessionText = (sessionId: string, text: string) => { + const current = this.$session.get(); + if (!current || current.id !== sessionId) { + return; + } + this.$session.set({ ...current, text }); + }; + + updateSessionPosition = (sessionId: string, position: Coordinate) => { + const current = this.$session.get(); + if (!current || current.id !== sessionId) { + return; + } + this.$session.set({ ...current, position }); + }; + + commitExistingSession = () => { + const session = this.$session.get(); + if (!session) { + return; + } + this.requestCommit(session.id); + }; + + onToolChanged = (prevTool: Tool, nextTool: Tool) => { + if (prevTool === 'text' && nextTool !== 'text') { + this.commitExistingSession(); + } + }; + + requestCommit = (sessionId: string) => { + const session = this.$session.get(); + if (!session || session.id !== sessionId) { + return; + } + const rawText = session.text.replace(/\r/g, ''); + if (!hasVisibleGlyphs(rawText)) { + this.clearSession(); + return; + } + + const textSettings = this.manager.stateApi.runSelector(selectCanvasTextSlice); + const canvasSettings = this.manager.stateApi.getSettings(); + const color = canvasSettings.activeColor === 'fgColor' ? canvasSettings.fgColor : canvasSettings.bgColor; + + this.$session.set({ + ...session, + status: coerceSessionStatus(transitionTextSessionStatus(session.status, 'COMMIT')), + }); + + void this.commitSession(session, rawText, textSettings, color); + }; + + private commitSession = async ( + session: CanvasTextSessionState, + rawText: string, + textSettings: CanvasTextSettingsState, + color: RgbaColor + ) => { + if (typeof document !== 'undefined' && document.fonts?.load) { + const fontSpec = buildFontDescriptor({ + fontFamily: getFontStackById(textSettings.fontId), + fontWeight: textSettings.bold ? 700 : 400, + fontStyle: textSettings.italic ? 'italic' : 'normal', + fontSize: textSettings.fontSize, + }); + try { + await document.fonts.load(fontSpec); + await document.fonts.ready; + } catch { + // Ignore font load failures and proceed with available metrics. + } + } + + const renderResult = renderTextToCanvas({ + text: rawText, + fontSize: textSettings.fontSize, + fontFamily: getFontStackById(textSettings.fontId), + fontWeight: textSettings.bold ? 700 : 400, + fontStyle: textSettings.italic ? 'italic' : 'normal', + underline: textSettings.underline, + strikethrough: textSettings.strikethrough, + lineHeight: textSettings.lineHeight, + color, + alignment: textSettings.alignment, + padding: TEXT_RASTER_PADDING, + devicePixelRatio: window.devicePixelRatio ?? 1, + }); + + const dataURL = renderResult.canvas.toDataURL('image/png'); + const imageState: CanvasImageState = { + id: getPrefixedId('image'), + type: 'image', + image: { + dataURL, + width: renderResult.totalWidth, + height: renderResult.totalHeight, + }, + }; + + const fallbackPosition = calculateLayerPosition( + session.anchor, + textSettings.alignment, + renderResult.contentWidth, + TEXT_RASTER_PADDING + ); + const position = session.position ? { x: session.position.x, y: session.position.y } : fallbackPosition; + + const selectedAdapter = this.manager.stateApi.getSelectedEntityAdapter(); + const addAfter = + selectedAdapter && selectedAdapter.state.type === 'raster_layer' ? selectedAdapter.state.id : undefined; + + this.manager.stateApi.addRasterLayer({ + overrides: { + objects: [imageState], + position, + name: this.buildLayerName(rawText), + }, + isSelected: true, + addAfter, + }); + + this.clearSession(); + }; + + private buildLayerName = (text: string) => { + const flattened = text.replace(/\s+/g, ' ').trim(); + if (!flattened) { + return 'Text'; + } + return flattened.length > 32 ? `${flattened.slice(0, 29)}…` : flattened; + }; +} diff --git a/invokeai/frontend/web/src/features/controlLayers/konva/CanvasTool/CanvasToolModule.ts b/invokeai/frontend/web/src/features/controlLayers/konva/CanvasTool/CanvasToolModule.ts index b9b8adae9b8..6ede5c5d5bc 100644 --- a/invokeai/frontend/web/src/features/controlLayers/konva/CanvasTool/CanvasToolModule.ts +++ b/invokeai/frontend/web/src/features/controlLayers/konva/CanvasTool/CanvasToolModule.ts @@ -6,6 +6,7 @@ import { CanvasColorPickerToolModule } from 'features/controlLayers/konva/Canvas import { CanvasEraserToolModule } from 'features/controlLayers/konva/CanvasTool/CanvasEraserToolModule'; import { CanvasMoveToolModule } from 'features/controlLayers/konva/CanvasTool/CanvasMoveToolModule'; import { CanvasRectToolModule } from 'features/controlLayers/konva/CanvasTool/CanvasRectToolModule'; +import { CanvasTextToolModule } from 'features/controlLayers/konva/CanvasTool/CanvasTextToolModule'; import { CanvasViewToolModule } from 'features/controlLayers/konva/CanvasTool/CanvasViewToolModule'; import { calculateNewBrushSizeFromWheelDelta, @@ -64,6 +65,7 @@ export class CanvasToolModule extends CanvasModuleBase { bbox: CanvasBboxToolModule; view: CanvasViewToolModule; move: CanvasMoveToolModule; + text: CanvasTextToolModule; }; /** @@ -119,6 +121,7 @@ export class CanvasToolModule extends CanvasModuleBase { rect: new CanvasRectToolModule(this), colorPicker: new CanvasColorPickerToolModule(this), bbox: new CanvasBboxToolModule(this), + text: new CanvasTextToolModule(this), view: new CanvasViewToolModule(this), move: new CanvasMoveToolModule(this), }; @@ -131,16 +134,20 @@ export class CanvasToolModule extends CanvasModuleBase { this.konva.group.add(this.tools.brush.konva.group); this.konva.group.add(this.tools.eraser.konva.group); this.konva.group.add(this.tools.colorPicker.konva.group); + this.konva.group.add(this.tools.text.konva.group); this.konva.group.add(this.tools.bbox.konva.group); this.subscriptions.add(this.manager.stage.$stageAttrs.listen(this.render)); this.subscriptions.add(this.manager.$isBusy.listen(this.render)); this.subscriptions.add(this.manager.stateApi.createStoreSubscription(selectCanvasSettingsSlice, this.render)); this.subscriptions.add(this.manager.stateApi.createStoreSubscription(selectCanvasSlice, this.render)); + let previousTool: Tool = this.$tool.get(); this.subscriptions.add( - this.$tool.listen(() => { + this.$tool.listen((nextTool) => { // On tool switch, reset mouse state this.manager.tool.$isPrimaryPointerDown.set(false); + void this.tools.text.onToolChanged(previousTool, nextTool); + previousTool = nextTool; this.render(); }) ); @@ -179,6 +186,8 @@ export class CanvasToolModule extends CanvasModuleBase { this.tools.bbox.syncCursorStyle(); } else if (tool === 'colorPicker') { this.tools.colorPicker.syncCursorStyle(); + } else if (tool === 'text') { + this.tools.text.syncCursorStyle(); } else if (selectedEntityAdapter) { if (selectedEntityAdapter.$isDisabled.get()) { stage.setCursor('not-allowed'); @@ -208,6 +217,7 @@ export class CanvasToolModule extends CanvasModuleBase { this.tools.brush.render(); this.tools.eraser.render(); this.tools.colorPicker.render(); + this.tools.text.render(); this.tools.bbox.render(); }; @@ -290,6 +300,19 @@ export class CanvasToolModule extends CanvasModuleBase { * @returns Whether the user is allowed to draw on the canvas. */ getCanDraw = (): boolean => { + const tool = this.$tool.get(); + if (tool === 'text') { + if (this.manager.$isBusy.get()) { + return false; + } + + if (this.manager.stage.getIsDragging()) { + return false; + } + + return true; + } + if (this.manager.stateApi.getRenderedEntityCount() === 0) { return false; } @@ -345,6 +368,8 @@ export class CanvasToolModule extends CanvasModuleBase { await this.tools.brush.onStagePointerEnter(e); } else if (tool === 'eraser') { await this.tools.eraser.onStagePointerEnter(e); + } else if (tool === 'text') { + await this.tools.text.onStagePointerEnter(e); } } finally { this.render(); @@ -375,6 +400,8 @@ export class CanvasToolModule extends CanvasModuleBase { await this.tools.eraser.onStagePointerDown(e); } else if (tool === 'rect') { await this.tools.rect.onStagePointerDown(e); + } else if (tool === 'text') { + await this.tools.text.onStagePointerDown(e); } } finally { this.render(); @@ -424,6 +451,8 @@ export class CanvasToolModule extends CanvasModuleBase { if (tool === 'colorPicker') { this.tools.colorPicker.onStagePointerMove(e); + } else if (tool === 'text') { + this.tools.text.onStagePointerMove(e); } if (!this.getCanDraw()) { @@ -436,6 +465,8 @@ export class CanvasToolModule extends CanvasModuleBase { await this.tools.eraser.onStagePointerMove(e); } else if (tool === 'rect') { await this.tools.rect.onStagePointerMove(e); + } else if (tool === 'text') { + // Already handled above } else { this.manager.stateApi.getSelectedEntityAdapter()?.bufferRenderer.clearBuffer(); } diff --git a/invokeai/frontend/web/src/features/controlLayers/store/canvasTextSlice.ts b/invokeai/frontend/web/src/features/controlLayers/store/canvasTextSlice.ts new file mode 100644 index 00000000000..eab044a4bd7 --- /dev/null +++ b/invokeai/frontend/web/src/features/controlLayers/store/canvasTextSlice.ts @@ -0,0 +1,104 @@ +import { createSelector, createSlice, type PayloadAction } from '@reduxjs/toolkit'; +import type { RootState } from 'app/store/store'; +import type { SliceConfig } from 'app/store/types'; +import { + TEXT_DEFAULT_ALIGNMENT, + TEXT_DEFAULT_FONT_ID, + TEXT_DEFAULT_FONT_SIZE, + TEXT_DEFAULT_LINE_HEIGHT, + TEXT_MAX_FONT_SIZE, + TEXT_MAX_LINE_HEIGHT, + TEXT_MIN_FONT_SIZE, + TEXT_MIN_LINE_HEIGHT, + type TextAlignment, + type TextFontId, + zTextAlignment, + zTextFontId, +} from 'features/controlLayers/text/textConstants'; +import { z } from 'zod'; + +const zCanvasTextSettingsState = z.object({ + fontId: zTextFontId, + fontSize: z.number().int().min(TEXT_MIN_FONT_SIZE).max(TEXT_MAX_FONT_SIZE), + bold: z.boolean(), + italic: z.boolean(), + underline: z.boolean(), + strikethrough: z.boolean(), + alignment: zTextAlignment, + lineHeight: z.number().min(TEXT_MIN_LINE_HEIGHT).max(TEXT_MAX_LINE_HEIGHT), +}); +export type CanvasTextSettingsState = z.infer; + +const getInitialState = (): CanvasTextSettingsState => ({ + fontId: TEXT_DEFAULT_FONT_ID, + fontSize: TEXT_DEFAULT_FONT_SIZE, + bold: false, + italic: false, + underline: false, + strikethrough: false, + alignment: TEXT_DEFAULT_ALIGNMENT, + lineHeight: TEXT_DEFAULT_LINE_HEIGHT, +}); + +const slice = createSlice({ + name: 'canvasText', + initialState: getInitialState(), + reducers: { + textFontChanged: (state, action: PayloadAction) => { + state.fontId = action.payload; + }, + textFontSizeChanged: (state, action: PayloadAction) => { + const next = Math.round(action.payload); + state.fontSize = Math.min(TEXT_MAX_FONT_SIZE, Math.max(TEXT_MIN_FONT_SIZE, next)); + }, + textBoldToggled: (state) => { + state.bold = !state.bold; + }, + textItalicToggled: (state) => { + state.italic = !state.italic; + }, + textUnderlineToggled: (state) => { + state.underline = !state.underline; + }, + textStrikethroughToggled: (state) => { + state.strikethrough = !state.strikethrough; + }, + textAlignmentChanged: (state, action: PayloadAction) => { + state.alignment = action.payload; + }, + textLineHeightChanged: (state, action: PayloadAction) => { + const next = action.payload; + state.lineHeight = Math.min(TEXT_MAX_LINE_HEIGHT, Math.max(TEXT_MIN_LINE_HEIGHT, next)); + }, + textSettingsReset: () => { + return getInitialState(); + }, + }, +}); + +export const { + textFontChanged, + textFontSizeChanged, + textBoldToggled, + textItalicToggled, + textUnderlineToggled, + textStrikethroughToggled, + textAlignmentChanged, +} = slice.actions; + +export const canvasTextSliceConfig: SliceConfig = { + slice, + schema: zCanvasTextSettingsState, + getInitialState, + persistConfig: { + migrate: (state) => zCanvasTextSettingsState.parse(state), + }, +}; + +export const selectCanvasTextSlice = (state: RootState) => state.canvasText; +const createCanvasTextSelector = (selector: (state: CanvasTextSettingsState) => T) => + createSelector(selectCanvasTextSlice, selector); + +export const selectTextFontId = createCanvasTextSelector((state) => state.fontId); +export const selectTextFontSize = createCanvasTextSelector((state) => state.fontSize); +export const selectTextAlignment = createCanvasTextSelector((state) => state.alignment); diff --git a/invokeai/frontend/web/src/features/controlLayers/store/types.ts b/invokeai/frontend/web/src/features/controlLayers/store/types.ts index 70732263f4a..716e9768711 100644 --- a/invokeai/frontend/web/src/features/controlLayers/store/types.ts +++ b/invokeai/frontend/web/src/features/controlLayers/store/types.ts @@ -102,7 +102,7 @@ const zIPMethodV2 = z.enum(['full', 'style', 'composition', 'style_strong', 'sty export type IPMethodV2 = z.infer; export const isIPMethodV2 = (v: unknown): v is IPMethodV2 => zIPMethodV2.safeParse(v).success; -const _zTool = z.enum(['brush', 'eraser', 'move', 'rect', 'view', 'bbox', 'colorPicker']); +const _zTool = z.enum(['brush', 'eraser', 'move', 'rect', 'view', 'bbox', 'colorPicker', 'text']); export type Tool = z.infer; const zPoints = z.array(z.number()).refine((points) => points.length % 2 === 0, { diff --git a/invokeai/frontend/web/src/features/controlLayers/text/textConstants.ts b/invokeai/frontend/web/src/features/controlLayers/text/textConstants.ts new file mode 100644 index 00000000000..276bd6f61f7 --- /dev/null +++ b/invokeai/frontend/web/src/features/controlLayers/text/textConstants.ts @@ -0,0 +1,144 @@ +import { z } from 'zod'; + +const TEXT_FONT_IDS = [ + 'sans', + 'serif', + 'mono', + 'rounded', + 'script', + 'humanist', + 'slab', + 'display', + 'narrow', + 'uiSerif', +] as const; +export const zTextFontId = z.enum(TEXT_FONT_IDS); +export type TextFontId = z.infer; + +export const TEXT_FONT_STACKS: Array<{ id: TextFontId; label: string; stack: string }> = [ + { + id: 'sans', + label: 'Sans', + stack: 'system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,sans-serif', + }, + { + id: 'serif', + label: 'Serif', + stack: 'Georgia,"Times New Roman",Times,serif', + }, + { + id: 'mono', + label: 'Monospace', + stack: 'ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,"Liberation Mono",monospace', + }, + { + id: 'rounded', + label: 'Rounded', + stack: '"Trebuchet MS",Verdana,"Segoe UI",sans-serif', + }, + { + id: 'script', + label: 'Script', + stack: '"Comic Sans MS","Comic Sans","Segoe UI",sans-serif', + }, + { + id: 'humanist', + label: 'Handwritten', + stack: + '"Savoye LET","Zapfino","Snell Roundhand","Apple Chancery","Edwardian Script ITC","Palace Script MT","URW Chancery L","Brush Script MT","Lucida Handwriting","Segoe Script","Segoe Print","Comic Sans MS","Comic Sans","Segoe UI",cursive', + }, + { + id: 'slab', + label: 'Slab Serif', + stack: '"Rockwell","Cambria","Georgia","Times New Roman",serif', + }, + { + id: 'display', + label: 'Display', + stack: '"Impact","Haettenschweiler","Franklin Gothic Medium",Arial,sans-serif', + }, + { + id: 'narrow', + label: 'Narrow', + stack: '"Arial Narrow","Roboto Condensed","Segoe UI",Arial,sans-serif', + }, + { + id: 'uiSerif', + label: 'UI Serif', + stack: '"Iowan Old Style","Palatino","Book Antiqua","Times New Roman",serif', + }, +]; + +export const TEXT_DEFAULT_FONT_ID: TextFontId = 'sans'; +export const TEXT_DEFAULT_FONT_SIZE = 48; +export const TEXT_MIN_FONT_SIZE = 8; +export const TEXT_MAX_FONT_SIZE = 500; +export const TEXT_DEFAULT_LINE_HEIGHT = 1.25; +export const TEXT_MIN_LINE_HEIGHT = 1; +export const TEXT_MAX_LINE_HEIGHT = 2; +export const TEXT_RASTER_PADDING = 4; + +const TEXT_ALIGNMENTS = ['left', 'center', 'right'] as const; +export const zTextAlignment = z.enum(TEXT_ALIGNMENTS); +export type TextAlignment = z.infer; +export const TEXT_DEFAULT_ALIGNMENT: TextAlignment = 'left'; + +const stripQuotes = (fontName: string) => fontName.replace(/^['"]+|['"]+$/g, ''); + +const splitFontStack = (stack: string) => stack.split(',').map((font) => stripQuotes(font.trim())); + +const isGenericFont = (fontName: string) => + fontName === 'serif' || fontName === 'sans-serif' || fontName === 'monospace' || fontName === 'cursive'; + +const FONT_PROBE_TEXT = 'abcdefghijklmnopqrstuvwxyz0123456789'; + +const getFontProbeContext = () => { + if (typeof document === 'undefined') { + return null; + } + const canvas = document.createElement('canvas'); + return canvas.getContext('2d'); +}; + +const isFontAvailable = (fontName: string): boolean => { + const ctx = getFontProbeContext(); + if (!ctx) { + return false; + } + const fontSize = 72; + const fallbackFonts = ['monospace', 'serif', 'sans-serif']; + for (const fallback of fallbackFonts) { + ctx.font = `${fontSize}px ${fallback}`; + const baseline = ctx.measureText(FONT_PROBE_TEXT).width; + ctx.font = `${fontSize}px "${fontName}",${fallback}`; + const measured = ctx.measureText(FONT_PROBE_TEXT).width; + if (measured !== baseline) { + return true; + } + } + return false; +}; + +/** + * Attempts to resolve the first available font in the stack. Falls back to the first entry if availability cannot be + * determined (e.g. server-side rendering or older browsers). + */ +export const resolveAvailableFont = (stack: string): string => { + const fontCandidates = splitFontStack(stack); + if (typeof document === 'undefined') { + return fontCandidates[0] ?? 'sans-serif'; + } + for (const candidate of fontCandidates) { + if (isGenericFont(candidate)) { + return candidate; + } + if (isFontAvailable(candidate)) { + return candidate; + } + } + return fontCandidates[0] ?? 'sans-serif'; +}; + +export const getFontStackById = (fontId: TextFontId): string => { + return TEXT_FONT_STACKS.find((font) => font.id === fontId)?.stack ?? TEXT_FONT_STACKS[0]?.stack ?? 'sans-serif'; +}; diff --git a/invokeai/frontend/web/src/features/controlLayers/text/textHotkeys.test.ts b/invokeai/frontend/web/src/features/controlLayers/text/textHotkeys.test.ts new file mode 100644 index 00000000000..1cbc2d63086 --- /dev/null +++ b/invokeai/frontend/web/src/features/controlLayers/text/textHotkeys.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it } from 'vitest'; + +import { isAllowedTextShortcut } from './textHotkeys'; + +describe('text hotkey suppression', () => { + const buildEvent = (key: string, options?: Partial) => + ({ + key, + ctrlKey: options?.ctrlKey ?? false, + metaKey: options?.metaKey ?? false, + }) as KeyboardEvent; + + it('allows copy/paste/undo/redo shortcuts', () => { + expect(isAllowedTextShortcut(buildEvent('c', { ctrlKey: true }))).toBe(true); + expect(isAllowedTextShortcut(buildEvent('v', { metaKey: true }))).toBe(true); + expect(isAllowedTextShortcut(buildEvent('z', { ctrlKey: true }))).toBe(true); + expect(isAllowedTextShortcut(buildEvent('y', { metaKey: true }))).toBe(true); + }); + + it('blocks other hotkeys by default', () => { + expect(isAllowedTextShortcut(buildEvent('b', { ctrlKey: true }))).toBe(false); + expect(isAllowedTextShortcut(buildEvent('Escape'))).toBe(false); + }); +}); diff --git a/invokeai/frontend/web/src/features/controlLayers/text/textHotkeys.ts b/invokeai/frontend/web/src/features/controlLayers/text/textHotkeys.ts new file mode 100644 index 00000000000..bd30c7a84f2 --- /dev/null +++ b/invokeai/frontend/web/src/features/controlLayers/text/textHotkeys.ts @@ -0,0 +1,7 @@ +export const isAllowedTextShortcut = (event: KeyboardEvent): boolean => { + if (event.metaKey || event.ctrlKey) { + const key = event.key.toLowerCase(); + return key === 'c' || key === 'v' || key === 'z' || key === 'y'; + } + return false; +}; diff --git a/invokeai/frontend/web/src/features/controlLayers/text/textRenderer.test.ts b/invokeai/frontend/web/src/features/controlLayers/text/textRenderer.test.ts new file mode 100644 index 00000000000..7883ca863fe --- /dev/null +++ b/invokeai/frontend/web/src/features/controlLayers/text/textRenderer.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from 'vitest'; + +import { calculateLayerPosition, computeAlignedX } from './textRenderer'; + +describe('text alignment helpers', () => { + it('computes x offsets for different alignments', () => { + expect(computeAlignedX(50, 100, 'left', 4)).toBe(4); + expect(computeAlignedX(50, 100, 'center', 4)).toBe(4 + (100 - 50) / 2); + expect(computeAlignedX(50, 100, 'right', 4)).toBe(4 + (100 - 50)); + }); + + it('calculates layer positions relative to anchor', () => { + const anchor = { x: 200, y: 300 }; + expect(calculateLayerPosition(anchor, 'left', 100, 4)).toEqual({ x: 196, y: 296 }); + expect(calculateLayerPosition(anchor, 'center', 100, 4)).toEqual({ x: 146, y: 296 }); + expect(calculateLayerPosition(anchor, 'right', 100, 4)).toEqual({ x: 96, y: 296 }); + }); + + it('uses top anchor regardless of line height', () => { + const anchor = { x: 200, y: 300 }; + expect(calculateLayerPosition(anchor, 'left', 100, 4)).toEqual({ x: 196, y: 296 }); + }); +}); diff --git a/invokeai/frontend/web/src/features/controlLayers/text/textRenderer.ts b/invokeai/frontend/web/src/features/controlLayers/text/textRenderer.ts new file mode 100644 index 00000000000..7c2b13e2883 --- /dev/null +++ b/invokeai/frontend/web/src/features/controlLayers/text/textRenderer.ts @@ -0,0 +1,189 @@ +import { rgbaColorToString } from 'common/util/colorCodeTransformers'; +import type { Coordinate, RgbaColor } from 'features/controlLayers/store/types'; +import type { TextAlignment } from 'features/controlLayers/text/textConstants'; + +type TextRenderConfig = { + text: string; + fontSize: number; + fontFamily: string; + fontWeight: number; + fontStyle: 'normal' | 'italic'; + underline: boolean; + strikethrough: boolean; + lineHeight: number; + color: RgbaColor; + alignment: TextAlignment; + padding: number; + devicePixelRatio: number; +}; + +export type TextMeasureConfig = { + text: string; + fontSize: number; + fontFamily: string; + fontWeight: number; + fontStyle: 'normal' | 'italic'; + lineHeight: number; +}; + +type TextMetrics = { + lines: string[]; + lineWidths: number[]; + lineHeightPx: number; + contentWidth: number; + contentHeight: number; + ascent: number; + descent: number; + actualAscent: number; + actualDescent: number; + baselineOffset: number; +}; + +type TextRenderResult = { + canvas: HTMLCanvasElement; + contentWidth: number; + contentHeight: number; + totalWidth: number; + totalHeight: number; +}; + +export const renderTextToCanvas = (config: TextRenderConfig): TextRenderResult => { + const measurement = measureTextContent(config); + const extraRightPadding = Math.ceil(config.fontSize * 0.26); + const extraLeftPadding = Math.ceil(config.fontSize * 0.12); + const totalWidth = Math.ceil(measurement.contentWidth + config.padding * 2 + extraRightPadding + extraLeftPadding); + const totalHeight = Math.ceil(measurement.contentHeight + config.padding * 2); + + const canvas = document.createElement('canvas'); + const ctx = canvas.getContext('2d'); + if (!ctx) { + throw new Error('Unable to acquire 2D context'); + } + const dpr = Math.max(1, config.devicePixelRatio); + canvas.width = Math.max(1, Math.ceil(totalWidth * dpr)); + canvas.height = Math.max(1, Math.ceil(totalHeight * dpr)); + canvas.style.width = `${totalWidth}px`; + canvas.style.height = `${totalHeight}px`; + ctx.scale(dpr, dpr); + ctx.font = buildFontDescriptor(config); + ctx.textBaseline = 'alphabetic'; + ctx.fillStyle = rgbaColorToString(config.color); + const dprScale = Math.max(1, config.devicePixelRatio); + + measurement.lines.forEach((line, index) => { + const text = line === '' ? ' ' : line; + const lineWidth = measurement.lineWidths[index] ?? 0; + const x = computeAlignedX(lineWidth, measurement.contentWidth, config.alignment, config.padding + extraLeftPadding); + const y = config.padding + measurement.baselineOffset + index * measurement.lineHeightPx; + const snappedX = snapToDpr(x, dprScale); + const snappedY = snapToDpr(y, dprScale); + ctx.fillText(text, snappedX, snappedY); + if (config.underline) { + const underlineY = snapToDpr(snappedY + config.fontSize * 0.08, dprScale); + ctx.fillRect(snappedX, underlineY, lineWidth, Math.max(1.5, config.fontSize * 0.1)); + } + if (config.strikethrough) { + const strikeY = snapToDpr(snappedY - measurement.actualAscent * 0.55, dprScale); + ctx.fillRect(snappedX, strikeY, lineWidth, Math.max(1.5, config.fontSize * 0.1)); + } + }); + + return { + canvas, + contentWidth: measurement.contentWidth, + contentHeight: measurement.contentHeight, + totalWidth, + totalHeight, + }; +}; + +export const measureTextContent = (config: TextMeasureConfig): TextMetrics => { + const lines = config.text.split(/\r?\n/); + const fontDescriptor = buildFontDescriptor(config); + const measurementCanvas = document.createElement('canvas'); + const measureCtx = measurementCanvas.getContext('2d'); + if (!measureCtx) { + throw new Error('Failed to build 2D context'); + } + measureCtx.font = fontDescriptor; + const sampleMetrics = measureCtx.measureText('Mg'); + const fallbackAscent = config.fontSize * 0.8; + const fallbackDescent = config.fontSize * 0.2; + const ascent = + sampleMetrics.fontBoundingBoxAscent || + sampleMetrics.actualBoundingBoxAscent || + sampleMetrics.emHeightAscent || + fallbackAscent; + const descent = + sampleMetrics.fontBoundingBoxDescent || + sampleMetrics.actualBoundingBoxDescent || + sampleMetrics.emHeightDescent || + fallbackDescent; + const actualAscent = sampleMetrics.actualBoundingBoxAscent || ascent; + const actualDescent = sampleMetrics.actualBoundingBoxDescent || descent; + const lineHeightPx = (ascent + descent) * config.lineHeight; + const extraLeading = Math.max(0, lineHeightPx - (ascent + descent)); + const baselineOffset = ascent + extraLeading / 2; + const lineWidths = lines.map((line) => measureCtx.measureText(line === '' ? ' ' : line).width); + const contentWidth = Math.max(...lineWidths, config.fontSize); + const contentHeight = Math.max(lines.length, 1) * lineHeightPx; + return { + lines, + lineWidths, + lineHeightPx, + contentWidth, + contentHeight, + ascent, + descent, + actualAscent, + actualDescent, + baselineOffset, + }; +}; + +export const computeAlignedX = (lineWidth: number, contentWidth: number, alignment: TextAlignment, padding: number) => { + if (alignment === 'center') { + return padding + (contentWidth - lineWidth) / 2; + } + if (alignment === 'right') { + return padding + (contentWidth - lineWidth); + } + return padding; +}; + +export const buildFontDescriptor = (config: { + fontStyle: 'normal' | 'italic'; + fontWeight: number; + fontSize: number; + fontFamily: string; +}) => { + const weight = config.fontWeight || 400; + return `${config.fontStyle === 'italic' ? 'italic ' : ''}${weight} ${config.fontSize}px ${config.fontFamily}`; +}; + +export const calculateLayerPosition = ( + anchor: Coordinate, + alignment: TextAlignment, + contentWidth: number, + padding: number, + extraLeftPadding: number = 0 +) => { + let offsetX = -padding - extraLeftPadding; + if (alignment === 'center') { + offsetX = -(contentWidth / 2) - padding - extraLeftPadding; + } else if (alignment === 'right') { + offsetX = -contentWidth - padding - extraLeftPadding; + } + return { + x: anchor.x + offsetX, + y: anchor.y - padding, + }; +}; + +export const hasVisibleGlyphs = (text: string): boolean => { + return text.replace(/\s+/g, '').length > 0; +}; + +const snapToDpr = (value: number, dpr: number): number => { + return Math.round(value * dpr) / dpr; +}; diff --git a/invokeai/frontend/web/src/features/controlLayers/text/textSessionMachine.test.ts b/invokeai/frontend/web/src/features/controlLayers/text/textSessionMachine.test.ts new file mode 100644 index 00000000000..82768bf7b7e --- /dev/null +++ b/invokeai/frontend/web/src/features/controlLayers/text/textSessionMachine.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, it } from 'vitest'; + +import { transitionTextSessionStatus } from './textSessionMachine'; + +describe('textSessionMachine', () => { + it('transitions from idle -> pending -> editing -> committed', () => { + let status = transitionTextSessionStatus('idle', 'BEGIN'); + expect(status).toBe('pending'); + status = transitionTextSessionStatus(status, 'EDIT'); + expect(status).toBe('editing'); + status = transitionTextSessionStatus(status, 'COMMIT'); + expect(status).toBe('committed'); + }); + + it('resets to idle on cancel from any state', () => { + expect(transitionTextSessionStatus('pending', 'CANCEL')).toBe('idle'); + expect(transitionTextSessionStatus('editing', 'CANCEL')).toBe('idle'); + expect(transitionTextSessionStatus('committed', 'CANCEL')).toBe('idle'); + }); + + it('ignores invalid transitions', () => { + expect(transitionTextSessionStatus('pending', 'BEGIN')).toBe('pending'); + expect(transitionTextSessionStatus('editing', 'EDIT')).toBe('editing'); + }); +}); diff --git a/invokeai/frontend/web/src/features/controlLayers/text/textSessionMachine.ts b/invokeai/frontend/web/src/features/controlLayers/text/textSessionMachine.ts new file mode 100644 index 00000000000..bf1e8f53fa9 --- /dev/null +++ b/invokeai/frontend/web/src/features/controlLayers/text/textSessionMachine.ts @@ -0,0 +1,38 @@ +export type TextSessionStatus = 'idle' | 'pending' | 'editing' | 'committed'; +type TextSessionEvent = 'BEGIN' | 'EDIT' | 'COMMIT' | 'CANCEL'; + +export const transitionTextSessionStatus = (status: TextSessionStatus, event: TextSessionEvent): TextSessionStatus => { + switch (status) { + case 'idle': + if (event === 'BEGIN') { + return 'pending'; + } + return status; + case 'pending': + if (event === 'EDIT') { + return 'editing'; + } + if (event === 'CANCEL') { + return 'idle'; + } + return status; + case 'editing': + if (event === 'COMMIT') { + return 'committed'; + } + if (event === 'CANCEL') { + return 'idle'; + } + return status; + case 'committed': + if (event === 'BEGIN') { + return 'pending'; + } + if (event === 'CANCEL') { + return 'idle'; + } + return status; + default: + return status; + } +}; diff --git a/invokeai/frontend/web/src/features/ui/layouts/CanvasWorkspacePanel.tsx b/invokeai/frontend/web/src/features/ui/layouts/CanvasWorkspacePanel.tsx index 6b46d4ce803..9e7e7d3bf42 100644 --- a/invokeai/frontend/web/src/features/ui/layouts/CanvasWorkspacePanel.tsx +++ b/invokeai/frontend/web/src/features/ui/layouts/CanvasWorkspacePanel.tsx @@ -14,6 +14,7 @@ import { CanvasHUD } from 'features/controlLayers/components/HUD/CanvasHUD'; import { InvokeCanvasComponent } from 'features/controlLayers/components/InvokeCanvasComponent'; import { SelectObject } from 'features/controlLayers/components/SelectObject/SelectObject'; import { StagingAreaContextProvider } from 'features/controlLayers/components/StagingArea/context'; +import { CanvasTextOverlay } from 'features/controlLayers/components/Text/CanvasTextOverlay'; import { PinnedFillColorPickerOverlay } from 'features/controlLayers/components/Tool/PinnedFillColorPickerOverlay'; import { CanvasToolbar } from 'features/controlLayers/components/Toolbar/CanvasToolbar'; import { Transform } from 'features/controlLayers/components/Transform/Transform'; @@ -80,6 +81,7 @@ export const CanvasWorkspacePanel = memo(() => { +