From 1fd0bb5ff09f5b4ad07c26e65a0f927313622332 Mon Sep 17 00:00:00 2001 From: Owais Jamil Date: Fri, 27 Feb 2026 00:54:21 -0600 Subject: [PATCH] fix: parts of speech range calculation + highlighting * added swatch for pos in settings --- src/__tests__/LayoutSettingsRoute.test.tsx | 24 + src/__tests__/pos-highlighting.test.ts | 61 ++- src/__tests__/style-check.test.ts | 3 +- .../LayoutSettingsPanel/Accessibility.tsx | 14 + .../LayoutSettingsPanel/ChromeSettings.tsx | 46 ++ .../LayoutSettingsPanel/EditorSettings.tsx | 50 +++ .../LayoutSettingsPanel/FocusModeSettings.tsx | 25 ++ .../LayoutSettingsPanel.tsx | 412 ++++-------------- .../LayoutSettingsPanel/QuickCapture.tsx | 20 + .../LayoutSettingsPanel/SettingsHeader.tsx | 39 ++ .../LayoutSettingsPanel/WriterTools.tsx | 128 ++++++ src/editor/constants.ts | 61 +++ src/editor/pos-highlighting.ts | 129 ++++-- src/editor/style-check.ts | 44 +- src/editor/types.ts | 45 ++ src/types.ts | 2 + src/utils/text.ts | 3 + 17 files changed, 674 insertions(+), 432 deletions(-) create mode 100644 src/components/layout/LayoutSettingsPanel/Accessibility.tsx create mode 100644 src/components/layout/LayoutSettingsPanel/ChromeSettings.tsx create mode 100644 src/components/layout/LayoutSettingsPanel/EditorSettings.tsx create mode 100644 src/components/layout/LayoutSettingsPanel/FocusModeSettings.tsx create mode 100644 src/components/layout/LayoutSettingsPanel/QuickCapture.tsx create mode 100644 src/components/layout/LayoutSettingsPanel/SettingsHeader.tsx create mode 100644 src/components/layout/LayoutSettingsPanel/WriterTools.tsx create mode 100644 src/editor/types.ts create mode 100644 src/utils/text.ts diff --git a/src/__tests__/LayoutSettingsRoute.test.tsx b/src/__tests__/LayoutSettingsRoute.test.tsx index cfb029a..6e9b75f 100644 --- a/src/__tests__/LayoutSettingsRoute.test.tsx +++ b/src/__tests__/LayoutSettingsRoute.test.tsx @@ -1,4 +1,5 @@ import { LayoutSettingsPanel, RoutedSettingsSheet } from "$components/layout/LayoutSettingsPanel"; +import { resetLayoutStore } from "$state/stores/layout"; import { resetUiStore, useUiStore } from "$state/stores/ui"; import { fireEvent, render, screen, waitFor } from "@testing-library/react"; import { beforeEach, describe, expect, it } from "vitest"; @@ -16,6 +17,7 @@ function renderSettingsSheets() { describe("Layout settings routing", () => { beforeEach(() => { + resetLayoutStore(); resetUiStore(); globalThis.history.replaceState(null, "", "#/"); }); @@ -49,4 +51,26 @@ describe("Layout settings routing", () => { expect(screen.getByRole("dialog", { name: "Settings" })).toBeInTheDocument(); }); + + it("shows POS color legend only when parts of speech highlighting is enabled", () => { + globalThis.history.replaceState(null, "", "#/settings"); + + render( + + + , + ); + + expect(screen.queryByText("Part of Speech Colors")).not.toBeInTheDocument(); + + fireEvent.click(screen.getByRole("button", { name: /Writer's Tools/ })); + fireEvent.click(screen.getByRole("switch", { name: "Parts of Speech Highlighting" })); + + expect(screen.getByText("Part of Speech Colors")).toBeInTheDocument(); + expect(screen.getByText("Noun")).toBeInTheDocument(); + expect(screen.getByText("Verb")).toBeInTheDocument(); + expect(screen.getByText("Adjective")).toBeInTheDocument(); + expect(screen.getByText("Adverb")).toBeInTheDocument(); + expect(screen.getByText("Conjunction")).toBeInTheDocument(); + }); }); diff --git a/src/__tests__/pos-highlighting.test.ts b/src/__tests__/pos-highlighting.test.ts index b1c97ce..1dfe09a 100644 --- a/src/__tests__/pos-highlighting.test.ts +++ b/src/__tests__/pos-highlighting.test.ts @@ -1,21 +1,39 @@ -import { posHighlighting, posHighlightingTheme } from "$editor/pos-highlighting"; +import { POS_HIGHLIGHT_LEGEND } from "$editor/constants"; +import { collectPosTokenRanges, posHighlighting, posHighlightingTheme } from "$editor/pos-highlighting"; import { EditorState } from "@codemirror/state"; import { EditorView } from "@codemirror/view"; import { describe, expect, it } from "vitest"; +import model from "wink-eng-lite-web-model"; +import winkNLP from "wink-nlp"; describe("posHighlighting", () => { - it("should apply POS decorations to text", () => { - const state = EditorState.create({ - doc: "The quick brown fox jumps.", - extensions: [posHighlighting(), posHighlightingTheme], - }); - const view = new EditorView({ state }); + const nlp = winkNLP(model); - expect(view.state.doc.toString()).toBe("The quick brown fox jumps."); - view.destroy(); + it("collects ranges using token character positions instead of token index", () => { + const text = "The quick brown fox jumps and runs quickly."; + const ranges = collectPosTokenRanges(text, nlp); + const highlighted = ranges.map((range) => ({ text: text.slice(range.from, range.to), className: range.className })); + + expect(highlighted).toContainEqual({ text: "fox", className: "cm-pos-noun" }); + expect(highlighted).toContainEqual({ text: "jumps", className: "cm-pos-verb" }); + expect(highlighted).toContainEqual({ text: "runs", className: "cm-pos-verb" }); + expect(highlighted).toContainEqual({ text: "quickly", className: "cm-pos-adverb" }); + }); + + it("accounts for multiple spaces when mapping ranges", () => { + const text = "Fox jumps\tquickly."; + const ranges = collectPosTokenRanges(text, nlp); + const highlighted = ranges.map((range) => text.slice(range.from, range.to)); + + expect(highlighted).toContain("jumps"); + expect(highlighted).toContain("quickly"); }); - it("should handle empty text", () => { + it("handles empty text", () => { + expect(collectPosTokenRanges("", nlp)).toStrictEqual([]); + }); + + it("creates editor extension without mutating text", () => { const state = EditorState.create({ doc: "", extensions: [posHighlighting(), posHighlightingTheme] }); const view = new EditorView({ state }); @@ -23,7 +41,7 @@ describe("posHighlighting", () => { view.destroy(); }); - it("should handle whitespace-only text", () => { + it("handles whitespace-only text", () => { const state = EditorState.create({ doc: " \n\n ", extensions: [posHighlighting(), posHighlightingTheme] }); const view = new EditorView({ state }); @@ -31,7 +49,7 @@ describe("posHighlighting", () => { view.destroy(); }); - it("should handle long text with viewport", () => { + it("handles long text with viewport", () => { const longText = "The quick brown fox jumps over the lazy dog. ".repeat(100); const state = EditorState.create({ doc: longText, extensions: [posHighlighting(), posHighlightingTheme] }); const view = new EditorView({ state }); @@ -40,16 +58,13 @@ describe("posHighlighting", () => { view.destroy(); }); - it("should apply theme CSS classes", () => { - const state = EditorState.create({ - doc: "The quick brown fox jumps over the lazy dog.", - extensions: [posHighlighting(), posHighlightingTheme], - }); - const view = new EditorView({ state }); - - const editorElement = view.dom; - expect(editorElement).toBeDefined(); - - view.destroy(); + it("exposes legend entries for settings UI", () => { + expect(POS_HIGHLIGHT_LEGEND.map((item) => item.label)).toStrictEqual([ + "Noun", + "Verb", + "Adjective", + "Adverb", + "Conjunction", + ]); }); }); diff --git a/src/__tests__/style-check.test.ts b/src/__tests__/style-check.test.ts index 46dcfc6..12a2d32 100644 --- a/src/__tests__/style-check.test.ts +++ b/src/__tests__/style-check.test.ts @@ -1,5 +1,6 @@ import { PatternMatcher } from "$editor/pattern-matcher"; -import { collectStyleMatches, resolveStyleMatchAtPosition, styleCheck, type StyleMatch } from "$editor/style-check"; +import { collectStyleMatches, resolveStyleMatchAtPosition, styleCheck } from "$editor/style-check"; +import type { StyleMatch } from "$editor/types"; import { EditorState, Text } from "@codemirror/state"; import { EditorView } from "@codemirror/view"; import { describe, expect, it, vi } from "vitest"; diff --git a/src/components/layout/LayoutSettingsPanel/Accessibility.tsx b/src/components/layout/LayoutSettingsPanel/Accessibility.tsx new file mode 100644 index 0000000..d4d11d5 --- /dev/null +++ b/src/components/layout/LayoutSettingsPanel/Accessibility.tsx @@ -0,0 +1,14 @@ +import { useReduceMotionState } from "$state/selectors"; +import { ToggleRow } from "./ToggleRow"; + +export function AccessibilitySection() { + const { reduceMotion, setReduceMotion } = useReduceMotionState(); + + return ( + + ); +} diff --git a/src/components/layout/LayoutSettingsPanel/ChromeSettings.tsx b/src/components/layout/LayoutSettingsPanel/ChromeSettings.tsx new file mode 100644 index 0000000..a18ecdc --- /dev/null +++ b/src/components/layout/LayoutSettingsPanel/ChromeSettings.tsx @@ -0,0 +1,46 @@ +import { useCreateReadmeState, useLayoutSettingsChromeState, useShowFilenamesState } from "$state/selectors"; +import { ToggleRow } from "./ToggleRow"; + +export function ChromeSettingsSection() { + const { + sidebarCollapsed, + topBarsCollapsed, + statusBarCollapsed, + toggleSidebarCollapsed, + toggleTabBarCollapsed, + toggleStatusBarCollapsed, + } = useLayoutSettingsChromeState(); + const { filenameVisibility: filenameVisibility, toggleFilenameVisibility: toggleFilenameVisibility } = + useShowFilenamesState(); + const { createReadmeInNewLocations, setCreateReadmeInNewLocations } = useCreateReadmeState(); + + return ( + <> + + + + + + + ); +} diff --git a/src/components/layout/LayoutSettingsPanel/EditorSettings.tsx b/src/components/layout/LayoutSettingsPanel/EditorSettings.tsx new file mode 100644 index 0000000..2987efd --- /dev/null +++ b/src/components/layout/LayoutSettingsPanel/EditorSettings.tsx @@ -0,0 +1,50 @@ +import { useLayoutSettingsEditorState } from "$state/selectors"; +import { EditorFontFamily } from "$types"; +import { ChangeEvent, useCallback } from "react"; +import { FontFamilyRow, FontSizeRow } from "./FontRows"; +import { ToggleRow } from "./ToggleRow"; + +export function EditorSettingsSection() { + const { + lineNumbersVisible, + textWrappingEnabled, + syntaxHighlightingEnabled, + editorFontSize, + editorFontFamily, + toggleLineNumbersVisible, + toggleTextWrappingEnabled, + toggleSyntaxHighlightingEnabled, + setEditorFontSize, + setEditorFontFamily, + } = useLayoutSettingsEditorState(); + + const handleFontSizeChange = useCallback((event: ChangeEvent) => { + setEditorFontSize(Number(event.target.value)); + }, [setEditorFontSize]); + + const handleFontFamilyChange = useCallback((event: ChangeEvent) => { + setEditorFontFamily(event.target.value as EditorFontFamily); + }, [setEditorFontFamily]); + + return ( + <> + + + + + + + ); +} diff --git a/src/components/layout/LayoutSettingsPanel/FocusModeSettings.tsx b/src/components/layout/LayoutSettingsPanel/FocusModeSettings.tsx new file mode 100644 index 0000000..e26294b --- /dev/null +++ b/src/components/layout/LayoutSettingsPanel/FocusModeSettings.tsx @@ -0,0 +1,25 @@ +import { useLayoutSettingsFocusState } from "$state/selectors"; +import { DimmingModeRow } from "./DimmingModeRow"; +import { ToggleRow } from "./ToggleRow"; + +export function FocusModeSection() { + const { focusModeSettings, setTypewriterScrollingEnabled, setFocusDimmingMode, setAutoEnterFocusMode } = + useLayoutSettingsFocusState(); + + return ( + <> + + + + + + ); +} diff --git a/src/components/layout/LayoutSettingsPanel/LayoutSettingsPanel.tsx b/src/components/layout/LayoutSettingsPanel/LayoutSettingsPanel.tsx index 3f7ce8a..866e11d 100644 --- a/src/components/layout/LayoutSettingsPanel/LayoutSettingsPanel.tsx +++ b/src/components/layout/LayoutSettingsPanel/LayoutSettingsPanel.tsx @@ -1,314 +1,74 @@ -import { Button } from "$components/Button"; import { CollapsibleSection } from "$components/CollapsibleSection"; -import { Sheet, type SheetPosition } from "$components/Sheet"; +import { Sheet, type SheetPosition, type SheetSize } from "$components/Sheet"; import { useRoutedSheet } from "$hooks/useRoutedSheet"; import { useViewportTier } from "$hooks/useViewportTier"; -import { XIcon } from "$icons"; -import { - useCreateReadmeState, - useGlobalCaptureSettingsState, - useLayoutSettingsChromeState, - useLayoutSettingsEditorState, - useLayoutSettingsFocusState, - useLayoutSettingsUiState, - useLayoutSettingsWriterToolsState, - useReduceMotionState, - useShowFilenamesState, -} from "$state/selectors"; -import type { EditorFontFamily, StyleMarkerStyle } from "$types"; -import { type ChangeEvent, useCallback, useEffect, useMemo, useState } from "react"; -import { CustomPatternControls } from "./CustomPatternControls"; -import { DimmingModeRow } from "./DimmingModeRow"; -import { FontFamilyRow, FontSizeRow } from "./FontRows"; -import { ToggleRow } from "./ToggleRow"; - -type SettingsScope = "basic" | "full"; -type SettingsSheetLayout = { position: SheetPosition; className: string; backdropClassName: string }; - -const STYLE_MARKER_OPTIONS: Array<{ value: StyleMarkerStyle; label: string }> = [ - { value: "highlight", label: "Highlight" }, - { value: "strikethrough", label: "Strikethrough" }, - { value: "underline", label: "Underline" }, -]; - -function SettingsHeader( - { title, onClose, closeAriaLabel, onViewMore }: { - title: string; - onClose: () => void; - closeAriaLabel: string; - onViewMore?: () => void; - }, -) { - return ( -
-

{title}

-
- {onViewMore && } - -
-
- ); -} - -const StyleMarkerRow = ( - { value, onChange }: { value: StyleMarkerStyle; onChange: (event: ChangeEvent) => void }, -) => ( -
- - -
-); - -function StyleCheckSection() { - const { styleCheckSettings, setStyleCheckSettings, setStyleCheckCategory, addCustomPattern, removeCustomPattern } = - useLayoutSettingsWriterToolsState(); - const [showCustom, setShowCustom] = useState(false); - - const toggleFiller = useCallback(() => { - setStyleCheckCategory("filler", !styleCheckSettings.categories.filler); - }, [setStyleCheckCategory, styleCheckSettings.categories.filler]); - - const toggleRedundancy = useCallback(() => { - setStyleCheckCategory("redundancy", !styleCheckSettings.categories.redundancy); - }, [setStyleCheckCategory, styleCheckSettings.categories.redundancy]); - - const toggleCliche = useCallback(() => { - setStyleCheckCategory("cliche", !styleCheckSettings.categories.cliche); - }, [setStyleCheckCategory, styleCheckSettings.categories.cliche]); - - const handleStyleCheckEnabled = useCallback((enabled: boolean) => { - setStyleCheckSettings({ ...styleCheckSettings, enabled }); - }, [setStyleCheckSettings, styleCheckSettings]); - - const handleMarkerStyleChange = useCallback((event: ChangeEvent) => { - setStyleCheckSettings({ ...styleCheckSettings, markerStyle: event.target.value as StyleMarkerStyle }); - }, [setStyleCheckSettings, styleCheckSettings]); - - return ( -
- - - {styleCheckSettings.enabled && ( -
-

Categories

- - - - - -
- )} -
- ); -} - -function ChromeSettingsSection() { - const { - sidebarCollapsed, - topBarsCollapsed, - statusBarCollapsed, - toggleSidebarCollapsed, - toggleTabBarCollapsed, - toggleStatusBarCollapsed, - } = useLayoutSettingsChromeState(); - const { filenameVisibility: filenameVisibility, toggleFilenameVisibility: toggleFilenameVisibility } = - useShowFilenamesState(); - const { createReadmeInNewLocations, setCreateReadmeInNewLocations } = useCreateReadmeState(); - - return ( - <> - - - - - - - ); -} - -function EditorSettingsSection() { - const { - lineNumbersVisible, - textWrappingEnabled, - syntaxHighlightingEnabled, - editorFontSize, - editorFontFamily, - toggleLineNumbersVisible, - toggleTextWrappingEnabled, - toggleSyntaxHighlightingEnabled, - setEditorFontSize, - setEditorFontFamily, - } = useLayoutSettingsEditorState(); - - const handleFontSizeChange = useCallback((event: ChangeEvent) => { - setEditorFontSize(Number(event.target.value)); - }, [setEditorFontSize]); - - const handleFontFamilyChange = useCallback((event: ChangeEvent) => { - setEditorFontFamily(event.target.value as EditorFontFamily); - }, [setEditorFontFamily]); - - return ( - <> - - - - - - - ); -} - -function FocusModeSection() { - const { focusModeSettings, setTypewriterScrollingEnabled, setFocusDimmingMode, setAutoEnterFocusMode } = - useLayoutSettingsFocusState(); - - return ( - <> - - - - - - ); -} - -function WriterToolsSection() { - const { posHighlightingEnabled, togglePosHighlighting } = useLayoutSettingsWriterToolsState(); - - return ( - <> - - - - - ); -} - -const QuickCaptureSection = () => { - const { settings, setQuickCaptureEnabled } = useGlobalCaptureSettingsState(); - const quickCaptureEnabled = settings.enabled; - - const handleQuickCaptureEnabledChange = useCallback((enabled: boolean) => { - setQuickCaptureEnabled(enabled); - }, [setQuickCaptureEnabled]); - - return ( - - ); -}; - -function AccessibilitySection() { - const { reduceMotion, setReduceMotion } = useReduceMotionState(); - - return ( - - ); -} +import { useLayoutSettingsUiState } from "$state/selectors"; +import type { SettingsScope } from "$types"; +import { cn } from "$utils/tw"; +import { useCallback, useEffect, useMemo } from "react"; +import { AccessibilitySection } from "./Accessibility"; +import { ChromeSettingsSection } from "./ChromeSettings"; +import { EditorSettingsSection } from "./EditorSettings"; +import { FocusModeSection } from "./FocusModeSettings"; +import { QuickCaptureSection } from "./QuickCapture"; +import { SettingsHeader } from "./SettingsHeader"; +import { WriterToolsSection } from "./WriterTools"; + +type SettingsSheetLayout = { position: SheetPosition; size: SheetSize; className: string; backdropClassName: string }; const SettingsBody = ({ scope }: { scope: SettingsScope }) => ( -
- +
+ - + - + {scope === "full" && ( <> - + - + - + @@ -316,22 +76,25 @@ const SettingsBody = ({ scope }: { scope: SettingsScope }) => (
); -function SettingsPanel( - { title, scope, onClose, closeAriaLabel, onViewMore }: { - title: string; - scope: SettingsScope; - onClose: () => void; - closeAriaLabel: string; - onViewMore?: () => void; - }, -) { - return ( -
- - -
- ); -} +type SettingsContentProps = { + title: string; + scope: SettingsScope; + onClose: () => void; + closeAriaLabel: string; + onViewMore?: () => void; +}; + +const SettingsContent = ({ title, scope, onClose, closeAriaLabel, onViewMore }: SettingsContentProps) => ( +
+ + +
+); function useSettingsSheetLayout(scope: SettingsScope): SettingsSheetLayout { const { isCompact, viewportWidth } = useViewportTier(); @@ -341,24 +104,26 @@ function useSettingsSheetLayout(scope: SettingsScope): SettingsSheetLayout { if (compactPanel) { return { position: "b", + size: scope === "basic" ? "lg" : "full", className: scope === "basic" - ? "left-3 right-3 bottom-3 max-h-[calc(100vh-5rem)] rounded-lg border" - : "left-3 right-3 bottom-3 max-h-[calc(100vh-2.5rem)] rounded-lg border", - backdropClassName: "bg-black/35", + ? "left-3 right-3 bottom-3 rounded-xl border" + : "left-2 right-2 top-2 bottom-2 rounded-2xl border shadow-2xl", + backdropClassName: scope === "basic" ? "bg-black/35" : "bg-black/45", }; } return { position: "r", + size: scope === "basic" ? "md" : "xl", className: scope === "basic" - ? "right-4 top-14 bottom-4 w-[360px] rounded-lg border" - : "right-4 top-4 bottom-4 w-[420px] rounded-lg border", - backdropClassName: scope === "basic" ? "bg-black/30" : "bg-black/35", + ? "right-4 top-14 bottom-4 rounded-xl border" + : "right-4 top-4 bottom-4 rounded-2xl border shadow-2xl", + backdropClassName: scope === "basic" ? "bg-black/30" : "bg-black/40", }; }, [compactPanel, scope]); } -export const LayoutSettingsPanel = () => { +export function LayoutSettingsPanel() { const { isOpen: isVisible, setOpen } = useLayoutSettingsUiState(); const { isOpen: isSettingsRouteOpen, open: openSettingsRoute } = useRoutedSheet("/settings"); const layout = useSettingsSheetLayout("basic"); @@ -383,10 +148,11 @@ export const LayoutSettingsPanel = () => { isOpen={isVisible} onClose={handleClose} position={layout.position} + size={layout.size} ariaLabel="Layout settings" backdropClassName={layout.backdropClassName} className={layout.className}> - { onViewMore={handleViewMore} /> ); -}; +} -export const RoutedSettingsSheet = () => { +export function RoutedSettingsSheet() { const { isOpen, close } = useRoutedSheet("/settings"); const layout = useSettingsSheetLayout("full"); @@ -405,11 +171,11 @@ export const RoutedSettingsSheet = () => { isOpen={isOpen} onClose={close} position={layout.position} + size={layout.size} ariaLabel="Settings" - size="xl" backdropClassName={layout.backdropClassName} className={layout.className}> - + ); -}; +} diff --git a/src/components/layout/LayoutSettingsPanel/QuickCapture.tsx b/src/components/layout/LayoutSettingsPanel/QuickCapture.tsx new file mode 100644 index 0000000..351166d --- /dev/null +++ b/src/components/layout/LayoutSettingsPanel/QuickCapture.tsx @@ -0,0 +1,20 @@ +import { useGlobalCaptureSettingsState } from "$state/selectors"; +import { useCallback } from "react"; +import { ToggleRow } from "./ToggleRow"; + +export const QuickCaptureSection = () => { + const { settings, setQuickCaptureEnabled } = useGlobalCaptureSettingsState(); + const quickCaptureEnabled = settings.enabled; + + const handleQuickCaptureEnabledChange = useCallback((enabled: boolean) => { + setQuickCaptureEnabled(enabled); + }, [setQuickCaptureEnabled]); + + return ( + + ); +}; diff --git a/src/components/layout/LayoutSettingsPanel/SettingsHeader.tsx b/src/components/layout/LayoutSettingsPanel/SettingsHeader.tsx new file mode 100644 index 0000000..52a5629 --- /dev/null +++ b/src/components/layout/LayoutSettingsPanel/SettingsHeader.tsx @@ -0,0 +1,39 @@ +import { Button } from "$components/Button"; +import { XIcon } from "$icons"; +import type { SettingsScope } from "$types"; +import { cn } from "$utils/tw"; + +type SettingsHeaderProps = { + title: string; + scope: SettingsScope; + onClose: () => void; + closeAriaLabel: string; + onViewMore?: () => void; +}; + +export function SettingsHeader({ title, scope, onClose, closeAriaLabel, onViewMore }: SettingsHeaderProps) { + const isFull = scope === "full"; + + return ( +
+
+

+ {title} +

+ {isFull && ( +

Expanded controls for layout and writing tools.

+ )} +
+
+ {onViewMore && } + +
+
+ ); +} diff --git a/src/components/layout/LayoutSettingsPanel/WriterTools.tsx b/src/components/layout/LayoutSettingsPanel/WriterTools.tsx new file mode 100644 index 0000000..8c1fa8d --- /dev/null +++ b/src/components/layout/LayoutSettingsPanel/WriterTools.tsx @@ -0,0 +1,128 @@ +import { POS_HIGHLIGHT_LEGEND } from "$editor/constants"; +import { useLayoutSettingsWriterToolsState } from "$state/selectors"; +import { StyleMarkerStyle } from "$types"; +import { ChangeEvent, useCallback, useState } from "react"; +import { CustomPatternControls } from "./CustomPatternControls"; +import { ToggleRow } from "./ToggleRow"; + +const STYLE_MARKER_OPTIONS: Array<{ value: StyleMarkerStyle; label: string }> = [ + { value: "highlight", label: "Highlight" }, + { value: "strikethrough", label: "Strikethrough" }, + { value: "underline", label: "Underline" }, +]; + +type StyleMarkerRowProps = { value: StyleMarkerStyle; onChange: (event: ChangeEvent) => void }; + +const StyleMarkerRow = ({ value, onChange }: StyleMarkerRowProps) => ( +
+ + +
+); + +const PosHighlightLegendRow = ({ label, swatchClassName }: { label: string; swatchClassName: string }) => ( +
  • +
  • +); + +const PosHighlightLegend = () => ( +
    +

    Part of Speech Colors

    +
      + {POS_HIGHLIGHT_LEGEND.map((item) => ( + + ))} +
    +
    +); + +function StyleCheckSection() { + const { styleCheckSettings, setStyleCheckSettings, setStyleCheckCategory, addCustomPattern, removeCustomPattern } = + useLayoutSettingsWriterToolsState(); + const [showCustom, setShowCustom] = useState(false); + + const toggleFiller = useCallback(() => { + setStyleCheckCategory("filler", !styleCheckSettings.categories.filler); + }, [setStyleCheckCategory, styleCheckSettings.categories.filler]); + + const toggleRedundancy = useCallback(() => { + setStyleCheckCategory("redundancy", !styleCheckSettings.categories.redundancy); + }, [setStyleCheckCategory, styleCheckSettings.categories.redundancy]); + + const toggleCliche = useCallback(() => { + setStyleCheckCategory("cliche", !styleCheckSettings.categories.cliche); + }, [setStyleCheckCategory, styleCheckSettings.categories.cliche]); + + const handleStyleCheckEnabled = useCallback((enabled: boolean) => { + setStyleCheckSettings({ ...styleCheckSettings, enabled }); + }, [setStyleCheckSettings, styleCheckSettings]); + + const handleMarkerStyleChange = useCallback((event: ChangeEvent) => { + setStyleCheckSettings({ ...styleCheckSettings, markerStyle: event.target.value as StyleMarkerStyle }); + }, [setStyleCheckSettings, styleCheckSettings]); + + return ( +
    + + + {styleCheckSettings.enabled && ( +
    +

    Categories

    + + + + + +
    + )} +
    + ); +} + +export function WriterToolsSection() { + const { posHighlightingEnabled, togglePosHighlighting } = useLayoutSettingsWriterToolsState(); + + return ( + <> + + {posHighlightingEnabled && } + + + + ); +} diff --git a/src/editor/constants.ts b/src/editor/constants.ts index f5d13b3..0bf4a9d 100644 --- a/src/editor/constants.ts +++ b/src/editor/constants.ts @@ -1,3 +1,64 @@ +import { PatternCategory } from "$types"; +import { PosLegendItem, StyleCheckConfig } from "./types"; + export const CATEGORY_LABELS = { filler: "Fillers & Weak Language", redundancy: "Redundancies", cliche: "Clichés" }; export const CATEGORY_COLORS = { filler: "#f97316", redundancy: "#eab308", cliche: "#ef4444" }; + +export const POS_LEGEND_ITEMS: readonly PosLegendItem[] = [ + { + label: "Noun", + className: "cm-pos-noun", + color: "#ef4444", + swatchClassName: "bg-[#ef4444]", + tags: ["NOUN", "PROPN"], + }, + { label: "Verb", className: "cm-pos-verb", color: "#3b82f6", swatchClassName: "bg-[#3b82f6]", tags: ["VERB", "AUX"] }, + { + label: "Adjective", + className: "cm-pos-adjective", + color: "#a87132", + swatchClassName: "bg-[#a87132]", + tags: ["ADJ"], + }, + { label: "Adverb", className: "cm-pos-adverb", color: "#8b5cf6", swatchClassName: "bg-[#8b5cf6]", tags: ["ADV"] }, + { + label: "Conjunction", + className: "cm-pos-conjunction", + color: "#22c55e", + swatchClassName: "bg-[#22c55e]", + tags: ["CONJ", "CCONJ", "SCONJ"], + }, +] as const; + +export const POS_CLASS_MAP = POS_LEGEND_ITEMS.reduce>((acc, item) => { + for (const tag of item.tags) { + acc[tag] = item.className; + } + return acc; +}, {}); + +export const POS_HIGHLIGHT_LEGEND = POS_LEGEND_ITEMS.map(({ label, className, color, swatchClassName }) => ({ + label, + className, + color, + swatchClassName, +})); + +export const POS_THEME = POS_LEGEND_ITEMS.reduce>((acc, item) => { + acc[`.${item.className}`] = { color: item.color }; + return acc; +}, {}); + +export const DEFAULT_CONFIG: StyleCheckConfig = { + enabled: true, + categories: { filler: true, redundancy: true, cliche: true }, + customPatterns: [], + markerStyle: "highlight", +}; + +export const DICTIONARY_CATEGORY_MAP: Record = { + fillers: "filler", + redundancies: "redundancy", + cliches: "cliche", +}; diff --git a/src/editor/pos-highlighting.ts b/src/editor/pos-highlighting.ts index fdee00b..ff99e49 100644 --- a/src/editor/pos-highlighting.ts +++ b/src/editor/pos-highlighting.ts @@ -1,7 +1,9 @@ +import { normalizeText } from "$utils/text"; import { RangeSetBuilder } from "@codemirror/state"; -import type { Extension } from "@codemirror/state"; +import type { EditorState as CMEditorState, Extension } from "@codemirror/state"; import { Decoration, DecorationSet, EditorView, ViewPlugin, ViewUpdate } from "@codemirror/view"; -import type { ItemToken } from "wink-nlp"; +import { POS_CLASS_MAP, POS_THEME } from "./constants"; +import type { PosNlp, PosToken } from "./types"; let nlpInstance: ReturnType | null = null; @@ -17,49 +19,69 @@ async function getNlp() { return nlpInstance; } -const POS_CLASS_MAP: Record = { - NOUN: "cm-pos-noun", - VERB: "cm-pos-verb", - ADJ: "cm-pos-adjective", - ADV: "cm-pos-adverb", - CONJ: "cm-pos-conjunction", - CCONJ: "cm-pos-conjunction", - SCONJ: "cm-pos-conjunction", -}; - function getPosClass(posTag: string): string | undefined { return POS_CLASS_MAP[posTag]; } -async function createPosDecorations(view: EditorView): Promise { - const nlp = await getNlp(); - const builder = new RangeSetBuilder(); - const { viewport } = view; - - const bufferStart = Math.max(0, viewport.from - 500); - const bufferEnd = Math.min(view.state.doc.length, viewport.to + 500); - - const text = view.state.doc.sliceString(bufferStart, bufferEnd); - +export function collectPosTokenRanges( + text: string, + nlp: PosNlp, +): Array<{ from: number; to: number; className: string }> { if (!text.trim()) { - return Decoration.none; + return []; } + const ranges: Array<{ from: number; to: number; className: string }> = []; const doc = nlp.readDoc(text); + let cursor = 0; + + doc.tokens().each((token: PosToken) => { + const leading = normalizeText(token.out(nlp.its.precedingSpaces)); + cursor += leading.length; - doc.tokens().each((token: ItemToken) => { - const posTag = token.out(nlp.its.pos); + const rawTokenText = token.out(nlp.its.value); + const tokenText = normalizeText(rawTokenText) || normalizeText(token.out()); + const posTag = normalizeText(token.out(nlp.its.pos)); const posClass = getPosClass(posTag); - if (posClass) { - const tokenIndex = token.index(); - const value = token.out(); - const start = tokenIndex + bufferStart; - const end = start + value.length; - builder.add(start, end, Decoration.mark({ class: posClass })); + const start = cursor; + const end = start + tokenText.length; + cursor = end; + + if (!posClass || tokenText.length === 0 || start >= end) { + return; } + + ranges.push({ from: start, to: end, className: posClass }); }); + return ranges; +} + +async function createPosDecorations( + state: CMEditorState, + viewport: { from: number; to: number }, +): Promise { + const nlp = await getNlp(); + const builder = new RangeSetBuilder(); + + const bufferStart = Math.max(0, viewport.from - 500); + const bufferEnd = Math.min(state.doc.length, viewport.to + 500); + + const text = state.doc.sliceString(bufferStart, bufferEnd); + const ranges = collectPosTokenRanges(text, nlp); + + for (const range of ranges) { + const start = bufferStart + range.from; + const end = bufferStart + range.to; + + if (start >= end || start < 0 || end > state.doc.length) { + continue; + } + + builder.add(start, end, Decoration.mark({ class: range.className })); + } + return builder.finish(); } @@ -67,31 +89,50 @@ export function posHighlighting(): Extension { return ViewPlugin.fromClass( class { decorations: DecorationSet = Decoration.none; + private requestId = 0; + private destroyed = false; constructor(private view: EditorView) { - void createPosDecorations(view).then((decs) => { + this.refresh(view.state, view.viewport); + } + + private refresh(state: CMEditorState, viewport: { from: number; to: number }) { + const activeRequest = this.requestId + 1; + this.requestId = activeRequest; + + void createPosDecorations(state, viewport).then((decs) => { + if (this.destroyed || activeRequest !== this.requestId) { + return; + } + this.decorations = decs; - this.view.dispatch({}); + if (this.view.state === state) { + this.view.dispatch({}); + } + }).catch(() => { + if (this.destroyed || activeRequest !== this.requestId) { + return; + } + + this.decorations = Decoration.none; + if (this.view.state === state) { + this.view.dispatch({}); + } }); } update(update: ViewUpdate) { if (update.docChanged || update.viewportChanged) { - void createPosDecorations(this.view).then((decs) => { - this.decorations = decs; - this.view.dispatch({}); - }); + this.refresh(update.state, update.view.viewport); } } + + destroy() { + this.destroyed = true; + } }, { decorations: (v) => v.decorations }, ); } -export const posHighlightingTheme = EditorView.theme({ - ".cm-pos-noun": { color: "#ef4444" }, - ".cm-pos-verb": { color: "#3b82f6" }, - ".cm-pos-adjective": { color: "#a87132" }, - ".cm-pos-adverb": { color: "#8b5cf6" }, - ".cm-pos-conjunction": { color: "#22c55e" }, -}); +export const posHighlightingTheme = EditorView.theme(POS_THEME); diff --git a/src/editor/style-check.ts b/src/editor/style-check.ts index 4152669..e1b79f3 100644 --- a/src/editor/style-check.ts +++ b/src/editor/style-check.ts @@ -14,53 +14,15 @@ * - Clichés: https://github.com/dundalek/no-cliches (MIT) */ -import { PatternCategory, StyleMarkerStyle } from "$types"; +import { StyleMarkerStyle } from "$types"; import { RangeSetBuilder, Text } from "@codemirror/state"; import type { Extension } from "@codemirror/state"; import { Decoration, DecorationSet, EditorView, hoverTooltip, ViewPlugin, ViewUpdate } from "@codemirror/view"; -import { CATEGORY_LABELS } from "./constants"; +import { CATEGORY_LABELS, DEFAULT_CONFIG, DICTIONARY_CATEGORY_MAP } from "./constants"; import styleDictionaries from "./data/style-dictionaries.json"; import type { Pattern } from "./pattern-matcher"; import { PatternMatcher } from "./pattern-matcher"; - -export type StyleCategory = PatternCategory; - -export type StyleMatch = { - from: number; - to: number; - text: string; - category: StyleCategory; - replacement?: string; - line: number; - column: number; -}; - -export type StyleCheckConfig = { - enabled: boolean; - categories: { filler: boolean; redundancy: boolean; cliche: boolean }; - customPatterns: Pattern[]; - markerStyle: StyleMarkerStyle; - onMatchesChange?: (matches: StyleMatch[]) => void; -}; - -const DEFAULT_CONFIG: StyleCheckConfig = { - enabled: true, - categories: { filler: true, redundancy: true, cliche: true }, - customPatterns: [], - markerStyle: "highlight", -}; - -type DictionaryEntry = { - label: string; - enabled: boolean; - patterns: Array<{ text: string; replacement: string | null; source?: string }>; -}; - -const DICTIONARY_CATEGORY_MAP: Record = { - fillers: "filler", - redundancies: "redundancy", - cliches: "cliche", -}; +import type { DictionaryEntry, StyleCheckConfig, StyleMatch } from "./types"; function loadBuiltinPatterns(): Pattern[] { const patterns: Pattern[] = []; diff --git a/src/editor/types.ts b/src/editor/types.ts new file mode 100644 index 0000000..ffacb6e --- /dev/null +++ b/src/editor/types.ts @@ -0,0 +1,45 @@ +import { PatternCategory, StyleMarkerStyle } from "$types"; +import { Pattern } from "./pattern-matcher"; + +export type PosLegendItem = { + label: string; + className: string; + color: string; + swatchClassName: string; + tags: readonly string[]; +}; + +export type PosToken = { out: (itsf?: unknown) => unknown }; + +export type PosDocument = { tokens: () => { each: (cb: (token: PosToken) => void) => void } }; + +export type PosNlp = { + its: { pos: unknown; precedingSpaces: unknown; value: unknown }; + readDoc: (text: string) => PosDocument; +}; + +export type StyleCategory = PatternCategory; + +export type StyleMatch = { + from: number; + to: number; + text: string; + category: StyleCategory; + replacement?: string; + line: number; + column: number; +}; + +export type StyleCheckConfig = { + enabled: boolean; + categories: { filler: boolean; redundancy: boolean; cliche: boolean }; + customPatterns: Pattern[]; + markerStyle: StyleMarkerStyle; + onMatchesChange?: (matches: StyleMatch[]) => void; +}; + +export type DictionaryEntry = { + label: string; + enabled: boolean; + patterns: Array<{ text: string; replacement: string | null; source?: string }>; +}; diff --git a/src/types.ts b/src/types.ts index 8f166bb..06cd212 100644 --- a/src/types.ts +++ b/src/types.ts @@ -134,3 +134,5 @@ export type CaptureSubmitInput = { }; export type Maybe = T | null | undefined; + +export type SettingsScope = "basic" | "full"; diff --git a/src/utils/text.ts b/src/utils/text.ts new file mode 100644 index 0000000..28a03bb --- /dev/null +++ b/src/utils/text.ts @@ -0,0 +1,3 @@ +export function normalizeText(value: unknown): string { + return typeof value === "string" ? value : ""; +} -- 2.51.2