Skip to content

Latest commit

 

History

History
804 lines (636 loc) · 21.3 KB

File metadata and controls

804 lines (636 loc) · 21.3 KB

Pen Stroke Width Slider Implementation Specification

Overview

This document specifies the implementation plan for adding a slider control to adjust stroke width continuously, replacing or supplementing the current discrete 3-button selection (Thin/Bold/Extra Bold). This provides users with fine-grained control over stroke width for more precise drawing.


1. Current Technical Architecture

1.1 Stroke Width Constants

File: packages/common/src/constants.ts (lines 402-406)

export const STROKE_WIDTH = {
  thin: 1,
  bold: 2,
  extraBold: 4,
} as const;

Current limitations:

  • Only 3 discrete values: 1, 2, and 4
  • No intermediate values (e.g., 1.5, 3, 5+)
  • Users cannot fine-tune stroke width for precise work

1.2 Default Element Properties

File: packages/common/src/constants.ts (lines 408-426)

export const DEFAULT_ELEMENT_PROPS = {
  // ...
  strokeWidth: 2,  // Default is "bold"
  // ...
};

1.3 App State

File: packages/excalidraw/types.ts (line 339)

currentItemStrokeWidth: number;

File: packages/excalidraw/appState.ts (lines 43, 174)

// Initialization
currentItemStrokeWidth: DEFAULT_ELEMENT_PROPS.strokeWidth,

// Persistence config - saved to browser localStorage
currentItemStrokeWidth: { browser: true, export: false, server: false },

1.4 Current Stroke Width UI

File: packages/excalidraw/actions/actionProperties.tsx (lines 544-600)

The current implementation uses RadioSelection component with 3 fixed options:

export const actionChangeStrokeWidth = register<
  ExcalidrawElement["strokeWidth"]
>({
  name: "changeStrokeWidth",
  label: "labels.strokeWidth",
  trackEvent: false,
  perform: (elements, appState, value) => {
    return {
      elements: changeProperty(elements, appState, (el) =>
        newElementWith(el, { strokeWidth: value }),
      ),
      appState: { ...appState, currentItemStrokeWidth: value },
      captureUpdate: CaptureUpdateAction.IMMEDIATELY,
    };
  },
  PanelComponent: ({ elements, appState, updateData, app, data }) => (
    <fieldset>
      <legend>{t("labels.strokeWidth")}</legend>
      <div className="buttonList">
        <RadioSelection
          group="stroke-width"
          options={[
            { value: STROKE_WIDTH.thin, text: t("labels.thin"), icon: StrokeWidthBaseIcon },
            { value: STROKE_WIDTH.bold, text: t("labels.bold"), icon: StrokeWidthBoldIcon },
            { value: STROKE_WIDTH.extraBold, text: t("labels.extraBold"), icon: StrokeWidthExtraBoldIcon },
          ]}
          value={getFormValue(...)}
          onChange={(value) => updateData(value)}
        />
      </div>
    </fieldset>
  ),
});

1.5 Existing Range Component (Opacity Slider)

File: packages/excalidraw/components/Range.tsx

The opacity slider provides a reference implementation:

export type RangeProps = {
  updateData: (value: number) => void;
  app: AppClassProperties;
  testId?: string;
};

export const Range = ({ updateData, app, testId }: RangeProps) => {
  // Uses <input type="range" min="0" max="100" step="10" />
  // Shows value bubble below slider
  // Animated gradient background showing progress
};

Key features:

  • Input range with min/max/step configuration
  • Value bubble showing current value
  • Gradient background indicating progress
  • Handles mixed selection states

File: packages/excalidraw/components/Range.scss

.range-wrapper {
  position: relative;
  padding-top: 10px;
  padding-bottom: 25px;
}

.range-input {
  width: 100%;
  height: 4px;
  background: var(--color-slider-track);
  // Custom thumb styling for webkit and moz
}

.value-bubble {
  position: absolute;
  bottom: 0;
  transform: translateX(-50%);
  font-size: 12px;
}

1.6 Element Stroke Width Property

File: packages/element/src/types.ts (lines 40-82)

type _ExcalidrawElementBase = Readonly<{
  // ...
  strokeWidth: number;  // Already supports any numeric value
  // ...
}>;

The element type already supports any numeric stroke width value - only the UI restricts it to 3 options.

1.7 FreeDraw Element Creation

File: packages/excalidraw/components/App.tsx (lines 8230-8247)

const element = newFreeDrawElement({
  // ...
  strokeWidth: this.state.currentItemStrokeWidth,
  // ...
});

2. Implementation Plan

2.1 Design Decision

Create a new StrokeWidthSlider component that:

  1. Provides continuous stroke width control via slider
  2. Keeps preset buttons for quick selection (Thin/Bold/Extra Bold)
  3. Shows current value numerically
  4. Supports a reasonable range (1-16) with step of 1

2.2 Implementation Steps

Step 1: Create StrokeWidthSlider Component

File: packages/excalidraw/components/StrokeWidthSlider.tsx (new file)

import React, { useEffect, useRef } from "react";
import { STROKE_WIDTH } from "@excalidraw/common";
import { t } from "../i18n";
import "./StrokeWidthSlider.scss";

import type { AppClassProperties } from "../types";

// Stroke width configuration
const STROKE_WIDTH_MIN = 1;
const STROKE_WIDTH_MAX = 16;
const STROKE_WIDTH_STEP = 1;

export type StrokeWidthSliderProps = {
  value: number;
  onChange: (value: number) => void;
  testId?: string;
};

export const StrokeWidthSlider = ({
  value,
  onChange,
  testId,
}: StrokeWidthSliderProps) => {
  const rangeRef = useRef<HTMLInputElement>(null);
  const valueRef = useRef<HTMLDivElement>(null);

  useEffect(() => {
    if (rangeRef.current && valueRef.current) {
      const rangeElement = rangeRef.current;
      const valueElement = valueRef.current;
      const inputWidth = rangeElement.offsetWidth;
      const thumbWidth = 16;
      const percentage = (value - STROKE_WIDTH_MIN) / (STROKE_WIDTH_MAX - STROKE_WIDTH_MIN);
      const position = percentage * (inputWidth - thumbWidth) + thumbWidth / 2;
      valueElement.style.left = `${position}px`;
      const percentFill = percentage * 100;
      rangeElement.style.background = `linear-gradient(to right, var(--color-slider-track) 0%, var(--color-slider-track) ${percentFill}%, var(--button-bg) ${percentFill}%, var(--button-bg) 100%)`;
    }
  }, [value]);

  return (
    <div className="stroke-width-slider">
      <div className="stroke-width-presets">
        <button
          type="button"
          className={`preset-button ${value === STROKE_WIDTH.thin ? "active" : ""}`}
          onClick={() => onChange(STROKE_WIDTH.thin)}
          title={t("labels.thin")}
        >
          {STROKE_WIDTH.thin}
        </button>
        <button
          type="button"
          className={`preset-button ${value === STROKE_WIDTH.bold ? "active" : ""}`}
          onClick={() => onChange(STROKE_WIDTH.bold)}
          title={t("labels.bold")}
        >
          {STROKE_WIDTH.bold}
        </button>
        <button
          type="button"
          className={`preset-button ${value === STROKE_WIDTH.extraBold ? "active" : ""}`}
          onClick={() => onChange(STROKE_WIDTH.extraBold)}
          title={t("labels.extraBold")}
        >
          {STROKE_WIDTH.extraBold}
        </button>
      </div>
      <div className="range-wrapper">
        <input
          ref={rangeRef}
          type="range"
          min={STROKE_WIDTH_MIN}
          max={STROKE_WIDTH_MAX}
          step={STROKE_WIDTH_STEP}
          value={value}
          onChange={(e) => onChange(Number(e.target.value))}
          className="range-input"
          data-testid={testId}
        />
        <div className="value-bubble" ref={valueRef}>
          {value}
        </div>
      </div>
    </div>
  );
};

Step 2: Create StrokeWidthSlider Styles

File: packages/excalidraw/components/StrokeWidthSlider.scss (new file)

@use "../css/variables.module" as *;

.excalidraw {
  .stroke-width-slider {
    display: flex;
    flex-direction: column;
    gap: 0.5rem;
  }

  .stroke-width-presets {
    display: flex;
    gap: 0.25rem;
    justify-content: center;

    .preset-button {
      @include outlineButtonIconStyles;
      min-width: 2rem;
      font-size: 0.75rem;
      font-weight: 500;

      &.active {
        background-color: var(--color-surface-primary-container);
        border-color: var(--color-brand-hover);
      }
    }
  }

  .stroke-width-slider .range-wrapper {
    position: relative;
    padding-top: 8px;
    padding-bottom: 20px;
  }

  .stroke-width-slider .range-input {
    width: 100%;
    height: 4px;
    -webkit-appearance: none;
    background: var(--color-slider-track);
    border-radius: 2px;
    outline: none;

    &::-webkit-slider-thumb {
      -webkit-appearance: none;
      appearance: none;
      width: 16px;
      height: 16px;
      background: var(--color-slider-thumb);
      border-radius: 50%;
      cursor: pointer;
      border: none;
    }

    &::-moz-range-thumb {
      width: 16px;
      height: 16px;
      background: var(--color-slider-thumb);
      border-radius: 50%;
      cursor: pointer;
      border: none;
    }
  }

  .stroke-width-slider .value-bubble {
    position: absolute;
    bottom: 0;
    transform: translateX(-50%);
    font-size: 11px;
    color: var(--text-primary-color);
    font-weight: 500;
  }
}

Step 3: Update actionChangeStrokeWidth

File: packages/excalidraw/actions/actionProperties.tsx

Modify the actionChangeStrokeWidth to use the new slider component:

import { StrokeWidthSlider } from "../components/StrokeWidthSlider";

export const actionChangeStrokeWidth = register<
  ExcalidrawElement["strokeWidth"]
>({
  name: "changeStrokeWidth",
  label: "labels.strokeWidth",
  trackEvent: false,
  perform: (elements, appState, value) => {
    return {
      elements: changeProperty(elements, appState, (el) =>
        newElementWith(el, {
          strokeWidth: value,
        }),
      ),
      appState: { ...appState, currentItemStrokeWidth: value },
      captureUpdate: CaptureUpdateAction.IMMEDIATELY,
    };
  },
  PanelComponent: ({ elements, appState, updateData, app }) => {
    const value = getFormValue(
      elements,
      app,
      (element) => element.strokeWidth,
      (element) => element.hasOwnProperty("strokeWidth"),
      (hasSelection) =>
        hasSelection ? null : appState.currentItemStrokeWidth,
    );

    return (
      <fieldset>
        <legend>{t("labels.strokeWidth")}</legend>
        <StrokeWidthSlider
          value={value ?? appState.currentItemStrokeWidth}
          onChange={(newValue) => updateData(newValue)}
          testId="strokeWidth-slider"
        />
      </fieldset>
    );
  },
});

Step 4: Add Stroke Width Range Constants

File: packages/common/src/constants.ts

Add constants for stroke width range:

// Add after existing STROKE_WIDTH constant
export const STROKE_WIDTH_RANGE = {
  min: 1,
  max: 16,
  step: 1,
} as const;

Step 5: Export New Component

File: packages/excalidraw/components/StrokeWidthSlider.tsx

Ensure proper exports.

File: packages/excalidraw/index.tsx (if needed for public API)

Add export if the component should be part of the public API.

2.3 Files to Create

File Purpose
packages/excalidraw/components/StrokeWidthSlider.tsx New slider component
packages/excalidraw/components/StrokeWidthSlider.scss Slider styles

2.4 Files to Modify

File Changes
packages/excalidraw/actions/actionProperties.tsx Update PanelComponent to use StrokeWidthSlider
packages/common/src/constants.ts Add STROKE_WIDTH_RANGE constant

2.5 No Changes Required

The following do not need modification:

  • Element types - Already supports numeric strokeWidth
  • App state - Already stores numeric value
  • Element creation - Already uses numeric value from state
  • Serialization - Already handles numeric values

3. Testing Strategy

3.1 Unit Tests

File: packages/excalidraw/tests/strokeWidthSlider.test.tsx (new file)

import React from "react";
import { Excalidraw } from "../index";
import { API } from "./helpers/api";
import { Pointer, UI } from "./helpers/ui";
import { act, fireEvent, render, screen } from "./test-utils";

import type { ExcalidrawElement } from "@excalidraw/element/types";

const { h } = window;
const mouse = new Pointer("mouse");

describe("Stroke Width Slider", () => {
  beforeEach(async () => {
    await render(<Excalidraw handleKeyboardGlobally={true} />);
  });

  afterEach(async () => {
    await act(async () => {});
  });

  describe("Slider Control", () => {
    it("should update stroke width when slider value changes", async () => {
      // Create a rectangle
      UI.clickTool("rectangle");
      mouse.down(10, 10);
      mouse.up(100, 100);

      const element = h.elements[0];
      expect(element.strokeWidth).toBe(2); // Default

      // Find and change slider
      const slider = screen.getByTestId("strokeWidth-slider");
      fireEvent.change(slider, { target: { value: "8" } });

      const updatedElement = h.elements[0];
      expect(updatedElement.strokeWidth).toBe(8);
    });

    it("should allow continuous values between presets", async () => {
      UI.clickTool("rectangle");
      mouse.down(10, 10);
      mouse.up(100, 100);

      const slider = screen.getByTestId("strokeWidth-slider");

      // Test value between thin (1) and bold (2)
      fireEvent.change(slider, { target: { value: "3" } });
      expect(h.elements[0].strokeWidth).toBe(3);

      // Test value between bold (2) and extraBold (4)
      fireEvent.change(slider, { target: { value: "5" } });
      expect(h.elements[0].strokeWidth).toBe(5);

      // Test value beyond extraBold
      fireEvent.change(slider, { target: { value: "12" } });
      expect(h.elements[0].strokeWidth).toBe(12);
    });

    it("should respect min and max bounds", async () => {
      UI.clickTool("rectangle");
      mouse.down(10, 10);
      mouse.up(100, 100);

      const slider = screen.getByTestId("strokeWidth-slider") as HTMLInputElement;

      expect(slider.min).toBe("1");
      expect(slider.max).toBe("16");
    });
  });

  describe("Preset Buttons", () => {
    it("should set stroke width to thin (1) when thin preset clicked", async () => {
      API.setAppState({ currentItemStrokeWidth: 8 });

      UI.clickTool("rectangle");
      mouse.down(10, 10);
      mouse.up(100, 100);

      // Click thin preset button
      const thinButton = screen.getByTitle("Thin");
      fireEvent.click(thinButton);

      expect(h.elements[0].strokeWidth).toBe(1);
    });

    it("should set stroke width to bold (2) when bold preset clicked", async () => {
      API.setAppState({ currentItemStrokeWidth: 8 });

      UI.clickTool("rectangle");
      mouse.down(10, 10);
      mouse.up(100, 100);

      const boldButton = screen.getByTitle("Bold");
      fireEvent.click(boldButton);

      expect(h.elements[0].strokeWidth).toBe(2);
    });

    it("should set stroke width to extraBold (4) when extraBold preset clicked", async () => {
      API.setAppState({ currentItemStrokeWidth: 1 });

      UI.clickTool("rectangle");
      mouse.down(10, 10);
      mouse.up(100, 100);

      const extraBoldButton = screen.getByTitle("Extra bold");
      fireEvent.click(extraBoldButton);

      expect(h.elements[0].strokeWidth).toBe(4);
    });

    it("should highlight active preset button", async () => {
      API.setAppState({ currentItemStrokeWidth: 2 });

      UI.clickTool("rectangle");
      mouse.down(10, 10);
      mouse.up(100, 100);

      const boldButton = screen.getByTitle("Bold");
      expect(boldButton.classList.contains("active")).toBe(true);
    });
  });

  describe("App State", () => {
    it("should update currentItemStrokeWidth when slider changes", async () => {
      expect(h.state.currentItemStrokeWidth).toBe(2); // Default

      UI.clickTool("rectangle");
      mouse.down(10, 10);
      mouse.up(100, 100);

      const slider = screen.getByTestId("strokeWidth-slider");
      fireEvent.change(slider, { target: { value: "10" } });

      expect(h.state.currentItemStrokeWidth).toBe(10);
    });

    it("should create new elements with current stroke width", async () => {
      API.setAppState({ currentItemStrokeWidth: 7 });

      UI.clickTool("rectangle");
      mouse.down(10, 10);
      mouse.up(100, 100);

      expect(h.elements[0].strokeWidth).toBe(7);
    });
  });

  describe("FreeDraw/Pen Tool", () => {
    it("should apply stroke width to freedraw elements", async () => {
      API.setAppState({ currentItemStrokeWidth: 6 });

      UI.clickTool("freedraw");
      mouse.down(10, 10);
      mouse.moveTo(50, 50);
      mouse.up(100, 100);

      const freedrawElement = h.elements[0];
      expect(freedrawElement.type).toBe("freedraw");
      expect(freedrawElement.strokeWidth).toBe(6);
    });

    it("should update freedraw stroke width via slider", async () => {
      UI.clickTool("freedraw");
      mouse.down(10, 10);
      mouse.moveTo(50, 50);
      mouse.up(100, 100);

      const slider = screen.getByTestId("strokeWidth-slider");
      fireEvent.change(slider, { target: { value: "14" } });

      expect(h.elements[0].strokeWidth).toBe(14);
    });
  });

  describe("Multiple Selection", () => {
    it("should update stroke width for all selected elements", async () => {
      // Create two rectangles
      UI.clickTool("rectangle");
      mouse.down(10, 10);
      mouse.up(50, 50);

      UI.clickTool("rectangle");
      mouse.down(100, 100);
      mouse.up(150, 150);

      // Select both
      API.setSelectedElements([h.elements[0], h.elements[1]]);

      // Change stroke width
      const slider = screen.getByTestId("strokeWidth-slider");
      fireEvent.change(slider, { target: { value: "9" } });

      expect(h.elements[0].strokeWidth).toBe(9);
      expect(h.elements[1].strokeWidth).toBe(9);
    });
  });
});

3.2 Visual Regression Tests

Add to existing snapshot tests:

it("renders element with custom stroke width correctly", async () => {
  const rect = API.createElement({
    type: "rectangle",
    x: 0,
    y: 0,
    width: 100,
    height: 100,
    strokeWidth: 8,
  });

  API.setElements([rect]);
  expect(h.elements).toMatchSnapshot();
});

it("renders freedraw with custom stroke width correctly", async () => {
  const freedraw = API.createElement({
    type: "freedraw",
    x: 0,
    y: 0,
    points: [[0, 0], [50, 50], [100, 0]],
    strokeWidth: 12,
  });

  API.setElements([freedraw]);
  expect(h.elements).toMatchSnapshot();
});

3.3 Integration Tests

Scenarios to test:

  1. Slider interaction

    • Drag slider to various positions
    • Verify element stroke width updates in real-time
    • Verify value bubble shows correct value
  2. Preset button interaction

    • Click each preset button
    • Verify slider position updates
    • Verify element stroke width matches preset
  3. Persistence

    • Set custom stroke width
    • Refresh page
    • Verify stroke width persists (localStorage)
  4. Undo/Redo

    • Change stroke width
    • Undo
    • Verify reverts to previous value
    • Redo
    • Verify restored
  5. Copy/Paste styles

    • Create element with custom stroke width
    • Copy styles
    • Paste to another element
    • Verify stroke width transferred

3.4 Manual Testing Checklist

  • Slider renders correctly in properties panel
  • Slider thumb moves smoothly
  • Value bubble shows current value and follows thumb
  • Preset buttons highlight when active
  • Clicking preset updates slider position
  • Stroke width changes are visible on canvas immediately
  • Works with all element types that have stroke (rectangle, ellipse, arrow, line, freedraw)
  • Works in dark mode
  • Works on mobile/touch devices
  • Keyboard accessibility (arrow keys adjust value)
  • RTL layout displays correctly

3.5 Test Commands

# Run all tests with snapshot updates
yarn test:update

# Run specific stroke width tests
yarn test packages/excalidraw/tests/strokeWidthSlider.test.tsx

# Type checking
yarn test:typecheck

# Lint and format
yarn fix

4. Implementation Sequence

  1. Phase 1: Component Creation

    • Create StrokeWidthSlider.tsx component
    • Create StrokeWidthSlider.scss styles
    • Add STROKE_WIDTH_RANGE constant
  2. Phase 2: Integration

    • Update actionChangeStrokeWidth PanelComponent
    • Import and use new slider component
  3. Phase 3: Testing

    • Write unit tests
    • Write integration tests
    • Run full test suite
    • Manual QA testing
  4. Phase 4: Polish

    • Verify accessibility
    • Test dark mode
    • Test RTL layout
    • Performance check (slider drag smoothness)

Appendix: Key File Locations

Purpose File Path
Stroke width constants packages/common/src/constants.ts:402-406
Default element props packages/common/src/constants.ts:408-426
Current stroke width action packages/excalidraw/actions/actionProperties.tsx:544-600
App state definition packages/excalidraw/types.ts:339
App state initialization packages/excalidraw/appState.ts:43
Opacity Range component (reference) packages/excalidraw/components/Range.tsx
Range styles (reference) packages/excalidraw/components/Range.scss
Element base type packages/element/src/types.ts:40-82
FreeDraw element type packages/element/src/types.ts:374-380
FreeDraw creation packages/excalidraw/components/App.tsx:8230-8247
Localization packages/excalidraw/locales/en.json:28,72-73,77