react-paper / react-paper-bindings

Paper.js bindings for React
http://react-paper.github.io/react-paper-bindings/
194 stars 36 forks source link

'_currentStyle' missing error when using Vite #89

Open Loosetooth opened 1 month ago

Loosetooth commented 1 month ago

I'm having some trouble getting react-paper-bindings to work with Vite.

I made a simple test repo to reproduce the error.

It contains two canvases, one uses react-paper-bindings:

react-paper-binding Canvas:

// src/components/Panel2D.tsx

import React, { useEffect, useState } from 'react';
import { Canvas, View, Layer, usePaperScope, Circle } from 'react-paper-bindings';
import { Grid } from './Grid';

type Panel2DProps = {
  // scope: paper.PaperScope
}

export const Panel2D = (props: Panel2DProps) => {
  const [isPaperScopeReady, setIsPaperScopeReady] = useState(false)

  const paperScope = usePaperScope()

  return (
    <Canvas width={500} height={500} scope={paperScope} onScopeReady={() => {
      setIsPaperScopeReady(true)
    }}>
      {isPaperScopeReady && <>
        <View>
          {/* background layer */}
          <Layer>
            <Grid />
          </Layer>
          {/* path layer */}
          <Layer>
            <Circle center={[0, 0]} radius={10} fillColor="red" />
          </Layer>
        </View>
      </>
      }
    </Canvas>
  );
}

This one does not render anything and throws the following error in the console:

Uncaught TypeError: Cannot read properties of null (reading '_currentStyle')
    at Path2._initialize
    at new Path2
    at createPath
    at new Line
    ...

I went digging a bit in the paper.js code, and it looks like it fails on this line. So paper.project seems to be null.

Regular paper.js javascript Canvas

This one works fine.

// src/components/MyCanvas.tsx
import { PaperScope } from 'paper/dist/paper-core';
import React, { useState, useEffect, useRef } from 'react';

export const MyCanvas = () => {
  const canvasRef = useRef<HTMLCanvasElement>(null);
  const [paperScope] = useState(new PaperScope());

  useEffect(() => {
    if(!canvasRef.current) return;
    paperScope.setup(canvasRef.current);
    const circle = new paperScope.Path.Circle({
      center: [80, 50],
      radius: 35,
      fillColor: 'black'
    });

    // @ts-ignore
    paperScope.view.draw();
  }, [paperScope]);

  return (
    <div>
      <canvas ref={canvasRef} />
    </div>
  );
};

Any ideas what is going wrong? Help is much appreciated.

HriBB commented 1 month ago

PaperJS uses "global" scope for the currently active canvas, so if you have multiple scopes, I think thats a problem, not Vite. I have a new, unpublished version locally, that works around this problem, but its hacky.

Loosetooth commented 1 month ago

Thanks for the hint!

It indeed seems to be the case that some paper.js variables are not accessible somehow. (They're in another scope?)

Feel free to share your "hacky" solution if you can, I will try to look into it further as well.

Loosetooth commented 1 month ago

I investigated a bit more, and indeed, there are two "javascript scopes" for some reason. Whatever that might mean exactly.

Taking that into account, I can work around the issue in the following way:

  1. create a PaperScope object
  2. call the scope.setup(canvasRef) function inside of the correct scope before handing it over to the Canvas component.

The following code gives an example, where I have a scope that I call .setup() on BEFORE I give it to the Canvas component:


import { useEffect, useRef, useState } from 'react';
import { Canvas, View, Layer, Circle } from 'react-paper-bindings';
import { Grid } from './Grid';

type Panel2DProps = {
  scope: paper.PaperScope
}

export const Panel2D = (props: Panel2DProps) => {
  const canvasRef = useRef<HTMLCanvasElement>(null);
  const [scope, setScope] = useState<paper.PaperScope | undefined>(undefined);

  useEffect(() => {
    if (!canvasRef.current) return;
    console.log("Calling setup on the canvas");
    props.scope.setup(canvasRef.current);
    setScope(props.scope);
  }, [props.scope, canvasRef.current]);

  return (
    <Canvas width={500} height={500} scope={scope} ref={canvasRef}>
      <View>
        {/* background layer */}
        <Layer>
          <Grid />
        </Layer>
        {/* path layer */}
        <Layer>
          <Circle center={[50, 50]} radius={10} fillColor="red" />
        </Layer>
      </View>
    </Canvas>
  );
}

And this renders the canvas with its content just fine.

Observations:

  1. when using vite, letting the scope.setup(ref) be called inside of the react-paper-bindings package always results in the wrong scope being set up
  2. once the PaperScope is incorrectly set up (.setup has been called), calling scope.setup() again from the correct javascript scope does not help.

Some ideas to move forwards with this:

  1. expose a hook or something, allowing us to call scope.setup() manually from the correct scope. (We could still default to the current scope.setup() being called in the Canvas component.)
  2. figure out why there are two javascript scopes, and make sure that the Canvas component uses the same scope as the rest of the project.

I guess 1. would be the easiest for me to implement at the moment, although I understand that this might be less ideal if it results in more complexity for the user. Currently I have no idea how or why there are multiple scopes in the sample Vite project.

HriBB commented 1 month ago

Check out the v3 branch ;)

HriBB commented 1 month ago

I think the insertItems: false is the key. https://github.com/react-paper/react-paper-bindings/blob/v3/src/Canvas.tsx#L55

But there are some "side effects", and I am not sure ATM what they are and how I solved it. Would need to dig through the code again ...

HriBB commented 1 month ago

Here's my local v3 version. You can do a diff, to see if there are any changes.

I can publish v3 to npm, but to be honest, I think paper.js is not actively maintained anymore ... Lehni did publish v0.12.18 two months ago, but before that, 2 years nothing ...

Maybe something like https://pixijs.com/ or https://konvajs.org/ is a more future-proof solution.

Anyway, here's the code

Canvas.tsx

import React, {
  forwardRef,
  useEffect,
  useImperativeHandle,
  useRef,
  useState,
} from 'react'
import { ConcurrentRoot } from 'react-reconciler/constants'
import { FiberRoot } from 'react-reconciler'
import paper from 'paper/dist/paper-core'
import { Renderer } from './Renderer'

export type PaperScopeSettings = {
  insertItems?: boolean
  applyMatrix?: boolean
  handleSize?: number
  hitTolerance?: number
}

export type CanvasProps = React.ComponentProps<'canvas'> & {
  width: number
  height: number
  settings?: PaperScopeSettings
  scope?: paper.PaperScope
  onScopeReady?: (scope: paper.PaperScope) => void
}

export type CanvasRef = HTMLCanvasElement | null
export type ScopeRef = paper.PaperScope | null | undefined
export type FiberRef = FiberRoot | null

export const Canvas = forwardRef<CanvasRef, CanvasProps>(function Canvas(
  { children, width, height, settings, scope, onScopeReady, ...props },
  forwardedRef
) {
  const [canvas, setCanvas] = useState<CanvasRef>(null)
  const canvasRef = useRef<CanvasRef>(null)
  const scopeRef = useRef<ScopeRef>(scope)
  const fiberRef = useRef<FiberRef>(null)

  useImperativeHandle<CanvasRef, CanvasRef>(
    forwardedRef,
    () => canvasRef.current
  )

  // create
  useEffect(() => {
    if (canvas instanceof HTMLCanvasElement) {
      if (!scopeRef.current) {
        scopeRef.current = new paper.PaperScope()
      }

      Object.assign(scopeRef.current.settings, {
        ...settings,
        insertItems: false,
      })

      scopeRef.current.setup(canvas)

      fiberRef.current = Renderer.createContainer(
        scopeRef.current,
        ConcurrentRoot,
        null,
        false,
        null,
        '',
        console.error,
        null
      )

      Renderer.updateContainer(null, fiberRef.current, null, () => null)

      if (typeof onScopeReady === 'function') {
        onScopeReady(scopeRef.current)
      }
    } else if (canvasRef.current) {
      setCanvas(canvasRef.current)
    }

    // destroy
    return () => {
      if (canvas) {
        if (fiberRef.current) {
          Renderer.updateContainer(null, fiberRef.current, null, () => null)
        }
        scopeRef.current = null
        canvasRef.current = null
        fiberRef.current = null
      }
    }
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [canvas])

  // update
  useEffect(() => {
    if (canvas && fiberRef.current) {
      Renderer.updateContainer(children, fiberRef.current, null, () => null)
    }
  }, [canvas, children])

  // resize
  useEffect(() => {
    if (canvas && scopeRef.current && scopeRef.current.view) {
      scopeRef.current.view.viewSize = new scopeRef.current.Size(width, height)
    }
  }, [canvas, width, height])

  return <canvas {...props} ref={canvasRef} />
})

Renderer.injectIntoDevTools({
  // @ts-ignore
  findHostInstanceByFiber: () => null,
  bundleType: process.env.NODE_ENV !== 'production' ? 1 : 0,
  version: React.version,
  rendererPackageName: 'react-konva',
})

Renderer.tsx

import Reconciler from 'react-reconciler'
import { DefaultEventPriority } from 'react-reconciler/constants'
import paper from 'paper/dist/paper-core'

import * as Item from './Items'

type Container = paper.PaperScope

type Instance =
  | paper.View
  | paper.Tool
  | paper.Layer
  | paper.Group
  | paper.Path
  | paper.CompoundPath
  | paper.SymbolItem
  | paper.PointText
  | paper.Raster

type Type = keyof typeof Item

type Index<T> = { [key: string]: T }
type Props = Index<any>

type ApplyProp = (instance: Instance, props: Props, prev: Props) => void

const applyProp: Index<ApplyProp> = {
  active: (instance, props) => {
    if (
      props.active &&
      (instance instanceof paper.Tool || instance instanceof paper.Layer)
    ) {
      instance.activate()
    }
  },
  point: (instance, props, prev) => {
    if (instance instanceof paper.Item) {
      instance.translate([
        props.point[0] - prev.point[0],
        props.point[1] - prev.point[1],
      ])
    }
  },
  center: (instance, props, prev) => {
    if (instance instanceof paper.Item) {
      instance.translate([
        props.center[0] - prev.center[0],
        props.center[1] - prev.center[1],
      ])
    }
  },
  radius: (instance, props, prev) => {
    if (instance instanceof paper.Item) {
      instance.scale(props.radius / prev.radius)
    }
  },
  rotation: (instance, props, prev) => {
    if (instance instanceof paper.Item) {
      if (props.rotation && prev.rotation) {
        instance.rotate(props.rotation - prev.rotation)
      } else {
        instance.rotation = props.rotation
      }
    }
  },
  size: (instance, props, prev) => {
    if (instance instanceof paper.Item) {
      instance.scale(props.size[0] / prev.size[0], props.size[1] / prev.size[1])
    }
  },
  visible: (instance, props) => {
    if (instance instanceof paper.Item && props.visible !== instance.visible) {
      instance.visible = props.visible
    }
  },
}

const applyProps = (
  instance: Instance,
  props: Props,
  prevProps: Props = {}
) => {
  const keys = Object.keys(props)
  const len = keys.length
  let i = 0
  // https://stackoverflow.com/a/7252102
  while (i < len) {
    const prop = keys[i]
    if (
      prop !== 'id' &&
      prop !== 'children' &&
      props[prop] !== prevProps[prop]
    ) {
      if (applyProp[prop]) {
        applyProp[prop](instance, props, prevProps)
      } else {
        instance[prop as keyof typeof instance] = props[prop]
      }
    }
    i++
  }
}

const getSymbolDefinition = (scope: Container, { id, name, svg }: Props) => {
  const key = id || name
  if (!key) throw new Error('Missing id or name prop on SymbolItem')
  if (!svg) throw new Error('Missing svg prop on SymbolItem')

  // return cached definition
  if (scope.symbols && scope.symbols[key]) {
    return scope.symbols[key]
  }

  // create symbols cache
  if (!scope.symbols) {
    scope.symbols = {}
  }

  // create definition
  const definition = new paper.SymbolDefinition(scope.project.importSVG(svg))
  scope.symbols[key] = definition

  // return created definition
  return definition
}

export const Renderer = Reconciler({
  createInstance: (type: Type, instanceProps: Props, scope: Container) => {
    const { children, ...other } = instanceProps
    const props: Props = { ...other, _project: scope.project }

    let instance: Instance

    // temporary paper fix for https://github.com/paperjs/paper.js/issues/2012
    if (scope.project !== paper.project) {
      scope.activate()
    }

    switch (type) {
      case Item.View:
        instance = scope.view
        instance.project = scope.project
        break
      case Item.Tool:
        instance = new scope.Tool()
        break
      case Item.Layer:
        instance = new scope.Layer(props)
        break
      case Item.Group:
        instance = new scope.Group(props)
        break
      case Item.Path:
        instance = new scope.Path(props)
        break
      case Item.CompoundPath:
        instance = new scope.CompoundPath(props)
        break
      case Item.Arc:
        instance = new scope.Path.Arc(props)
        break
      case Item.Circle:
        instance = new scope.Path.Circle(props)
        break
      case Item.Ellipse:
        instance = new scope.Path.Ellipse(props)
        break
      case Item.Line:
        instance = new scope.Path.Line(props)
        break
      case Item.Rectangle:
        instance = new scope.Path.Rectangle(props)
        break
      case Item.RegularPolygon:
        instance = new scope.Path.RegularPolygon(props)
        break
      case Item.PointText:
        instance = new scope.PointText(props)
        break
      case Item.SymbolItem: {
        const definition = getSymbolDefinition(scope, props)
        instance = new scope.SymbolItem(definition, props.center)
        break
      }
      case Item.Raster: {
        instance = new scope.Raster(props)
        break
      }
      default:
        throw new Error(`PaperRenderer does not support the type "${type}"`)
    }

    instance.props = other
    instance.type = type

    return instance
  },

  createTextInstance: () => {
    throw new Error('PaperRenderer does not support text children')
  },

  getPublicInstance: (instance: Instance) => instance,
  prepareForCommit: () => null,
  prepareUpdate: () => true,
  resetAfterCommit: () => {},
  resetTextContent: () => {},
  getRootHostContext: () => null,
  getChildHostContext: () => null,
  shouldSetTextContent: () => false,

  getCurrentEventPriority: () => DefaultEventPriority,
  getInstanceFromNode: () => undefined,
  getInstanceFromScope: () => undefined,
  preparePortalMount: () => {},
  prepareScopeUpdate: () => {},
  beforeActiveInstanceBlur: () => {},
  afterActiveInstanceBlur: () => {},
  detachDeletedInstance: () => {},
  clearContainer: () => {},

  scheduleTimeout: setTimeout,
  cancelTimeout: clearTimeout,
  noTimeout: -1,
  isPrimaryRenderer: false,
  warnsIfNotActing: false,
  supportsMutation: true,
  supportsHydration: false,
  supportsPersistence: false,

  appendInitialChild: (parent: Instance, child: Instance) => {
    if (parent instanceof paper.Group && child instanceof paper.Item) {
      child.addTo(parent)
    } else if (
      parent instanceof paper.CompoundPath &&
      child instanceof paper.Item
    ) {
      child.addTo(parent)
    } else if (parent instanceof paper.View && child instanceof paper.Item) {
      child.addTo(parent.project)
    } else {
      throw new Error(`[react-paper-bindings] missing appendInitialChild logic`)
    }
  },

  finalizeInitialChildren: (instance: Instance, _type: Type, props: Props) => {
    if (
      instance instanceof paper.View ||
      instance instanceof paper.Tool ||
      instance instanceof paper.Layer
    ) {
      applyProps(instance, props)
    }
    return false
  },

  appendChild: (parent: Instance, child: Instance) => {
    if (parent instanceof paper.Group && child instanceof paper.Item) {
      child.addTo(parent)
    } else if (parent instanceof paper.View && child instanceof paper.Item) {
      child.addTo(parent.project)
    } else {
      throw new Error(`[react-paper-bindings] missing appendChild logic`)
    }
  },

  appendChildToContainer: (_container: Container, child: Instance) => {
    if (!(child instanceof paper.View || child instanceof paper.Tool)) {
      throw new Error(
        '[react-paper-bindings] Canvas can only hold View and Tool nodes'
      )
    }
  },

  insertBefore: (parent: Instance, child: Instance, before: Instance) => {
    if (
      parent instanceof paper.Group &&
      child instanceof paper.Item &&
      before instanceof paper.Item
    ) {
      child.insertAbove(before)
    } else if (
      parent instanceof paper.View &&
      child instanceof paper.Layer &&
      before instanceof paper.Layer
    ) {
      child.insertAbove(before)
    } else {
      throw new Error(`[react-paper-bindings] missing insertBefore logic`)
    }
  },

  insertInContainerBefore: (
    _container: Container,
    child: Instance,
    before: Instance
  ) => {
    if (
      !(child instanceof paper.View || child instanceof paper.Tool) ||
      !(before instanceof paper.View || before instanceof paper.Tool)
    ) {
      throw new Error(
        `[react-paper-bindings] Canvas can only hold View and Tool nodes`
      )
    }
  },

  removeChild: (_parent: Instance, child: Instance) => {
    if (typeof child.remove === 'function') {
      child.remove()
    } else {
      throw new Error(`[react-paper-bindings] missing removeChild logic`)
    }
  },

  removeChildFromContainer: (_container: Container, child: Instance) => {
    if (typeof child.remove === 'function') {
      child.remove()
    } else {
      throw new Error(
        `[react-paper-bindings] missing removeChildFromContainer logic`
      )
    }
  },

  commitTextUpdate: () => {},

  commitMount: () => {},

  commitUpdate: (
    instance: Instance,
    _payload: unknown,
    _type: Type,
    oldProps: Props,
    newProps: Props
  ) => {
    applyProps(instance, newProps, oldProps)
  },
})
HriBB commented 1 month ago

Looking at your code above, I think the problem is, that at first render, the scope is undefined so paper.js uses its own internal scope or maybe even creates a new one ... Who knows :)

PaperJS was created before the "bundler era" and does not play well with webpack or vite. I asked Lehni about it, but he said that it would be too much work to "fix" this. Basically it would be easier to rewrite from scratch