Skip to content

feat(scribing): migrate from fabric.js v5 to v7#8287

Open
adi-herwana-nus wants to merge 1 commit intomasterfrom
adi/scribing-library-updates
Open

feat(scribing): migrate from fabric.js v5 to v7#8287
adi-herwana-nus wants to merge 1 commit intomasterfrom
adi/scribing-library-updates

Conversation

@adi-herwana-nus
Copy link
Copy Markdown
Contributor

Migration of scribing question components from fabric.js v5.3.0 to v7.2.0. This addresses several vulnerabilities associated with the node-tar package (fabric v7 removes this from the dependency tree entirely).

Associated Redux state reducer and several top-level components (ScribingCanvas, ScribingToolbar, LayersComponent) also migrated from JSX class components to TSX functional components.

Sample of new submission with Fabric v7.2.0 objects:

Screenshot 2026-04-06 at 12 25 04

Database view of above:

Screenshot 2026-04-06 at 12 25 18

Sample of existing submission with Fabric v5.3.0 objects opened with Fabric 7.2.0 frontend:

Screenshot 2026-04-06 at 12 25 42

Note that no database migrations were needed to maintain backward compatibility.

ScribingCanvas.jsx → ScribingCanvas.tsx

Function Line (Old) Line (New) Notes
constructor 76 152 Converted to useRef hooks for shape/flag tracking
currentStateIndex getter 89 Read via scribingRef.current.currentStateIndex
canvasStates getter 93 Read via scribingRef.current.canvasStates
componentDidMount 97 1014 Converted to useEffect([answerId])
shouldComponentUpdate 102 1133 Split into multiple targeted useEffect hooks
deleteActiveObjects 179 444
onKeyDown 193 506
onMouseDownCanvas 286 594 Stale closure fixed via sync-ref pattern (scribingRef.current = scribingState[answerId] during render)
onMouseMoveCanvas 462 758
onMouseOut 550 853
onMouseOver 554 857
onMouseUpCanvas 560 863
onObjectMovingCanvas 601 905 Refactored to use getBoundingRect() offset — works for any originX/originY
onObjectSelected 617 1073 Uses unified listener-based model
onSelectionCleared 623 1073 Uses unified listener-based model
onTextChanged 627 928 Added eager scribingRef.current mutation to fix Fabric v7 event ordering (text:editing:exited now fires before mouse:down)
getCanvasPoint 637 328
getFabricObjectsFromJson 651 122 Now async (Fabric v7 fromObject returns a Promise)
getMousePoint 671 340
scribblesAsJson getter 676 233 Converted to function; uses layer.scribbleGroup.set({ visible })
setCopiedCanvasObjectPosition 703 424
rehydrateCanvas 726 271 Preserves backgroundImage across canvas.clear()
setCanvasStateAndUpdateAnswer 746 307 Now async
cloneText 760 402
denormaliseScribble 782 118
disableObjectSelection 786 372
enableObjectSelection 797 380 Rewritten to directly toggle selectable on existing canvas objects — avoids JSON round-trip that caused stale re-render when closing style popovers
generateMouseDragProperties 819 355
initializeCanvas 826 1014 Inlined into useEffect; canvas.dispose() now awaited — Fabric v7 dispose() is async (fixes insertBefore DOM error on first load)
initializeScribblesAndBackground 907 946 Now async
normaliseScribble 979 96
updateAnswer 997 294 Now async
saveScribbles 1005 458 Added isSavingScribbles re-entrant guard — Fabric v7 obj.set() fires object:modified, causing an infinite loop not present in old component
scaleCanvas 1033 Replaced by CSS zoom on canvas wrapper via ResizeObserver — scales dynamically without re-encoding canvas objects
undo 1039 485
redo 1045 492
render 1057 1261 Converted to component JSX return

ScribingToolbar.jsx → ScribingToolbar.tsx

Function Line (Old) Line (New) Notes
initializeColorDropdowns 90 74
initializePopovers 98 82
constructor 107 103 State converted to useState hooks; forceUpdate via useReducer for selection and layer changes
onChangeCompleteColor 119 119
onChangeFontFamily 123 128
onChangeFontSize 126 137
onChangeSliderThickness 129 146
onClickColorPicker 133 156
onClickDelete 143 167
onClickDrawingMode 147 171
onClickLineMode 153 182
onClickLineStyleChip 160 195
onClickMoveMode 166 205
onClickPopover 173 216
onClickRedo 187 231
onClickSelectionMode 191 235
onClickShapeMode 198 246
onClickTypingChevron 205 269
onClickTypingIcon 210 274
onClickTypingMode 214 259
onClickUndo 220 278
onClickZoomIn 224 282
onClickZoomOut 229 287
onRequestCloseColorPicker 234 292
onRequestClosePopover 243 299
getActiveObjectSelectedLineStyle 252 306
getSavingStatus 271 328
setSelectedShape 286 343
setToSelectTool 290 349
render 297 417 Converted to component JSX return

Other Significant Changes

  • Shift in object originX / originY values
    Fabric v7 shifts the default originX,originY values from left,top to center,center respectively.
    There are proposals to deprecate these fields entirely, so we are making the shift now to make future transitions to Fabric v8+ easier.
  • Non-serializable values removed from Redux state
    • activeObject no longer tracked in Redux. Exposed directly via ScribingCanvasRef.getActiveObject(), re-read on each render triggered by onSelectionChange.
    • selection:created/updated/cleared events drive a pub/sub onSelectionChange callback on ScribingCanvasRef, which the toolbar subscribes to via useEffect to trigger a forceUpdate.
    • layers tracking moved from Redux state to an internal useRef in ScribingCanvas.
  • Canvas ref sharing
    Lifted to index.tsx. ScribingCanvas uses forwardRef + useImperativeHandle, exposing ScribingCanvasRef (getActiveObject, getCanvasWidth, getLayers, setLayerDisplay, onSelectionChange).
  • Stale closures
    All 13 canvas interaction-tracking variables (mouseDownFlag, line, rect, etc.) converted from useStateuseRef. Redux state accessed via sync-ref pattern (scribingRef.current = scribingState[answerId] during render).
  • withCanvas HOF
    Guards every canvas event handler and useEffect against missing canvas/scribing. Returns a no-op if either is absent. Allows useEffect(withCanvas(...), [deps]) directly.
  • Dynamic canvas scaling
    CSS zoom on canvas wrapper div, updated by a ResizeObserver + requestAnimationFrame debounce (avoids "ResizeObserver loop" errors). Replaces scaleCanvas(). This allows scribing element to resize dynamically.
  • Layer display re-render
    forceUpdate via useReducer in toolbar triggers re-read of canvasRef.getLayers() after setLayerDisplay.
  • Shape fill toggle fix (ColorPickerField)
    Now restores fill alpha to 1 when "no fill" is unchecked (previously only set alpha to 0 on check). Existing-shape ShapePopover derives noFillValue from activeObject.fill regex rather than the stale scribing.hasNoFill flag.

Remaining Work

  • Update scribing question form to be in line with other question types
  • Support null images (and add graceful handling of when image failed to load)
  • Add scribing to past answers view (create read-only viewer)
  • Ctrl + A to select all objects
  • Redesign scribing layer selector

- ported top-level components and Redux state reducer from JS to TS
- minor improvements to component CSS
- implemented new CSS zoom-based canvas scaling
- fixed LayersComponent
- fixed toggling between fill / no fill for existing shapes
@adi-herwana-nus adi-herwana-nus force-pushed the adi/scribing-library-updates branch from 2d2762d to 542c249 Compare April 6, 2026 04:46
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Migrates the course assessment submission “scribing” feature from fabric.js v5 to v7, refactors the main scribing UI components from class-based JSX to functional TSX, and modernizes scribing state management to a Redux Toolkit slice to support the new Fabric v7 async/object model and reduce dependency vulnerabilities.

Changes:

  • Upgrade fabric dependency to v7.2.0 and update scribing canvas/toolbar implementations for Fabric v7 APIs and event ordering.
  • Replace legacy scribing reducer/action patterns with a Redux Toolkit slice (scribingActions) and wire explicit initialization into submission fetch.
  • Convert scribing UI components (Canvas/Toolbar/Layers/Fields) to TSX functional components and introduce a shared imperative ScribingCanvasRef.

Reviewed changes

Copilot reviewed 22 out of 23 changed files in this pull request and generated 7 comments.

Show a summary per file
File Description
client/tailwind.config.ts Adds a custom Tailwind utility used by the new scribing layout.
client/package.json Pins fabric.js to v7.2.0.
client/app/types/course/assessment/submission/answer/scribing.ts Introduces explicit TS interfaces for scribing answer payloads.
client/app/bundles/course/assessment/submission/reducers/scribing/index.ts New RTK slice for scribing state + actions.
client/app/bundles/course/assessment/submission/reducers/scribing.js Removes legacy Immer-based scribing reducer.
client/app/bundles/course/assessment/submission/propTypes.js Removes legacy scribing PropTypes shapes (now TS-driven).
client/app/bundles/course/assessment/submission/constants.ts Reworks scribing constants into typed as const arrays + union types.
client/app/bundles/course/assessment/submission/components/ScribingView/ScribingToolbar.tsx New TSX functional toolbar using hooks + canvas ref.
client/app/bundles/course/assessment/submission/components/ScribingView/ScribingToolbar.jsx Removes legacy class-based toolbar.
client/app/bundles/course/assessment/submission/components/ScribingView/ScribingCanvas.tsx New TSX functional canvas with Fabric v7 async hydration + imperative handle.
client/app/bundles/course/assessment/submission/components/ScribingView/ScribingCanvas.jsx Removes legacy class-based canvas.
client/app/bundles/course/assessment/submission/components/ScribingView/LayersComponent.tsx New TSX functional layers popover component.
client/app/bundles/course/assessment/submission/components/ScribingView/LayersComponent.jsx Removes legacy class-based layers component.
client/app/bundles/course/assessment/submission/components/ScribingView/index.tsx Lifts and shares the canvas imperative ref; lazy-loads new TSX components.
client/app/bundles/course/assessment/submission/components/ScribingView/index.jsx Removes legacy ScribingView component wrapper.
client/app/bundles/course/assessment/submission/components/ScribingView/fields/ShapeField.tsx Converts shape selector field to TSX + typed shape unions.
client/app/bundles/course/assessment/submission/components/ScribingView/fields/ShapeField.jsx Removes legacy JSX shape field.
client/app/bundles/course/assessment/submission/components/ScribingView/fields/ColorPickerField.jsx Fixes “no fill” toggle to correctly restore alpha on uncheck.
client/app/bundles/course/assessment/submission/components/ScribingView/test/index.test.tsx Updates test to use new scribingActions API.
client/app/bundles/course/assessment/submission/actions/index.js Dispatches scribingActions.initialize during submission fetch.
client/app/bundles/course/assessment/submission/actions/answers/scribing.ts New thunk for updating scribing answer via API using RTK slice status flags.
client/app/bundles/course/assessment/submission/actions/answers/scribing.js Removes legacy scribing action creators module.
Comments suppressed due to low confidence (1)

client/app/bundles/course/assessment/submission/actions/index.js:104

  • scribingActions.initialize is dispatched on fetchSubmission, but the scribing state was previously also re-initialized on other submission lifecycle successes (e.g., finalise/unsubmit) when the server returns a fresh data.answers payload. Consider dispatching scribingActions.initialize({ answers: data.answers }) in those success paths too, otherwise the scribing slice can become stale relative to FETCH_SUBMISSION_SUCCESS payloads.
        dispatch(
          historyActions.initSubmissionHistory({
            submissionId: data.submission.id,
            questionHistories: data.history.questions,
            questions: data.questions,
          }),
        );
        dispatch(scribingActions.initialize({ answers: data.answers }));
        dispatch(initiateAnswerFlagsForAnswers({ answers: data.answers }));
        dispatch(
          initiateLiveFeedbackChatPerQuestion({
            answerIds: data.answers.map((answer) => answer.id),
          }),
        );
      })

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +95 to +101
const ScribingToolbar: FC<ScribingToolbarProps> = ({ answerId, canvasRef }) => {
const scribings = useAppSelector(
(state) => state.assessments.submission.scribing,
);
const scribing = scribings[answerId];
const dispatch = useAppDispatch();
const { t } = useTranslation();
Copy link

Copilot AI Apr 6, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

scribing can be undefined on initial render (e.g., before scribingActions.initialize runs). This component dereferences scribing.* throughout (e.g., zoom handlers) without a guard, which will throw. Add an early return/loading state when scribing is missing (or provide a selector default).

Copilot uses AI. Check for mistakes.
const [popoverAnchor, setPopoverAnchor] = useState<HTMLElement | null>(null);
const [, forceUpdate] = useReducer((x: number) => x + 1, 0);

useEffect(() => canvasRef?.onSelectionChange(forceUpdate), [canvasRef]);
Copy link

Copilot AI Apr 6, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The selection-change subscription created in useEffect isn't cleaned up. onSelectionChange returns an unsubscribe function, but the effect currently ignores it, which can leak listeners and cause duplicate forceUpdate calls if canvasRef changes or the toolbar unmounts. Return the unsubscribe function from the effect.

Suggested change
useEffect(() => canvasRef?.onSelectionChange(forceUpdate), [canvasRef]);
useEffect(() => {
const unsubscribe = canvasRef?.onSelectionChange(forceUpdate);
return unsubscribe;
}, [canvasRef]);

Copilot uses AI. Check for mistakes.
// Create layer for each user's scribble
// Scribbles in layers have selection disabled
if (scribble.creator_id !== userId) {
const scribbleGroup = new Group(fabricObjs);
Copy link

Copilot AI Apr 6, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

getFabricObjectsFromJson can return undefined, but new Group(fabricObjs) is called without a fallback. If a scribble has empty/invalid content, this will throw. Default fabricObjs to an empty array (or skip creating the layer) before constructing the Group.

Suggested change
const scribbleGroup = new Group(fabricObjs);
const scribbleGroup = new Group(fabricObjs ?? []);

Copilot uses AI. Check for mistakes.
Comment on lines +1107 to +1110
container.tabIndex = 1000;
container.addEventListener('keydown', onKeyDown, false);

let rafId: number;
Copy link

Copilot AI Apr 6, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A keydown listener is added to container but never removed in the cleanup function. This can leak handlers across mounts and cause duplicate keyboard handling. Remove the event listener in the effect cleanup (and ensure the same handler reference is used).

Copilot uses AI. Check for mistakes.
Comment on lines +233 to +251
const scribblesAsJson = (
canvas: Canvas,
scribing: ScribingAnswerState,
): string => {
// Remove non-user scribings in canvas
layers.current.forEach((layer) => {
if (layer.creator_id !== scribing.answer.user_id) {
layer.scribbleGroup.set({ visible: false });
}
});
canvas.renderAll();

// Only save rescaled user scribings
const objects = canvas.getObjects();
objects.forEach((obj) => {
normaliseScribble(canvas, obj);
});
const json = JSON.stringify(objects);

Copy link

Copilot AI Apr 6, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

scribblesAsJson only hides non-user layer groups (visible: false) but still serializes canvas.getObjects() with JSON.stringify. Hidden layer groups will still be included in the saved JSON because they remain in the objects array. To ensure only the current user's objects are persisted, exclude layer groups from export (e.g., set excludeFromExport on layer groups) or filter them out of the serialized list.

Copilot uses AI. Check for mistakes.
Comment on lines 56 to 66
dispatch({
type: actionTypes.FETCH_SUBMISSION_SUCCESS,
payload: mockSubmission,
});

const loaded = true;
const url = `/courses/${global.courseId}/assessments/${assessmentId}/submissions/${submissionId}/edit`;

dispatch(setCanvasLoaded(answerId, loaded));
dispatch(scribingActions.setCanvasLoaded({ answerId, loaded }));

const page = render(<ScribingView answerId={answerId} />, { at: [url] });
Copy link

Copilot AI Apr 6, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This test dispatches FETCH_SUBMISSION_SUCCESS directly, but the new scribing slice is initialized via scribingActions.initialize (dispatched by fetchSubmission), not by handling FETCH_SUBMISSION_SUCCESS. As a result, setCanvasLoaded is a no-op because state.scribing[answerId] doesn't exist. Initialize the scribing slice in the test before calling setCanvasLoaded (or dispatch fetchSubmission with a mocked API response).

Copilot uses AI. Check for mistakes.
Comment on lines 43 to 90
@@ -76,52 +82,11 @@ export const scribingTools = mirrorCreator([
'ZOOM_IN',
'ZOOM_OUT',
'DELETE',
]);
] as const;
export type ScribingTool = (typeof SCRIBING_TOOLS)[number];

export const scribingShapes = mirrorCreator(['RECT', 'ELLIPSE']);

export const canvasActionTypes = mirrorCreator([
'SET_CANVAS',
'SET_CANVAS_LOADED',
'SET_TOOL_SELECTED',
'SET_FONT_FAMILY',
'SET_FONT_SIZE',
'SET_LINE_STYLE_CHIP',
'SET_COLORING_TOOL_COLOR',
'SET_TOOL_THICKNESS',
'SET_SELECTED_SHAPE',
'SET_NO_FILL',
'SET_CANVAS_LOADED',
'OPEN_POPOVER',
'CLOSE_POPOVER',
'ADD_LAYER',
'SET_LAYER_DISPLAY',
'SET_CANVAS_PROPERTIES',
'SET_DRAWING_MODE',
'SET_CANVAS_CURSOR',
'SET_CURRENT_STATE_INDEX',
'SET_CANVAS_STATES',
'UPDATE_CANVAS_STATE',
'SET_ACTIVE_OBJECT',
'SET_CANVAS_ZOOM',
'RESET_CHANGE_TOOL',
'DELETE_CANVAS_OBJECT',
'RESET_CANVAS_DELETE',
'SET_DISABLE_OBJECT_SELECTION',
'RESET_DISABLE_OBJECT_SELECTION',
'SET_ENABLE_OBJECT_SELECTION',
'RESET_ENABLE_OBJECT_SELECTION',
'SET_ENABLE_TEXT_SELECTION',
'RESET_ENABLE_TEXT_SELECTION',
'SET_CANVAS_DIRTY',
'RESET_CANVAS_DIRTY',
'SET_CANVAS_SAVE',
'RESET_CANVAS_SAVE',
'SET_UNDO',
'RESET_UNDO',
'SET_REDO',
'RESET_REDO',
]);
const SCRIBING_SHAPES = ['RECT', 'ELLIPSE'] as const;
export type ScribingShape = (typeof SCRIBING_SHAPES)[number];

Copy link

Copilot AI Apr 6, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This change removes/renames the previously exported scribing constant objects (e.g., scribingTools, scribingToolColor, scribingToolThickness, scribingToolLineStyle, scribingShapes, canvasActionTypes). There are still references in the repo (e.g., components/ScribingView/__test__/ScribingToolbar.test.jsx) that will break until updated (or until equivalent runtime exports are restored).

Copilot uses AI. Check for mistakes.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants