Put SnapshotEditor inside a Pane
This commit is contained in:
parent
921bc44270
commit
64273366ed
17 changed files with 395 additions and 50 deletions
|
@ -1,10 +1,12 @@
|
|||
import type {Studio} from '@theatre/studio/Studio'
|
||||
import projectsSingleton from './projects/projectsSingleton'
|
||||
import {privateAPI} from './privateAPIs'
|
||||
import * as coreExports from './coreExports'
|
||||
|
||||
export type CoreBits = {
|
||||
projectsP: typeof projectsSingleton.atom.pointer.projects
|
||||
privateAPI: typeof privateAPI
|
||||
coreExports: typeof coreExports
|
||||
}
|
||||
|
||||
export default class CoreBundle {
|
||||
|
@ -27,6 +29,7 @@ export default class CoreBundle {
|
|||
const bits: CoreBits = {
|
||||
projectsP: projectsSingleton.atom.pointer.projects,
|
||||
privateAPI: privateAPI,
|
||||
coreExports,
|
||||
}
|
||||
|
||||
callback(bits)
|
||||
|
|
121
theatre/studio/src/PaneManager.ts
Normal file
121
theatre/studio/src/PaneManager.ts
Normal file
|
@ -0,0 +1,121 @@
|
|||
import {prism, val} from '@theatre/dataverse'
|
||||
import {emptyArray} from '@theatre/shared/utils'
|
||||
import SimpleCache from '@theatre/shared/utils/SimpleCache'
|
||||
import type {$FixMe, $IntentionalAny} from '@theatre/shared/utils/types'
|
||||
import type {Studio} from './Studio'
|
||||
import type {PaneInstance} from './TheatreStudio'
|
||||
|
||||
export default class PaneManager {
|
||||
private readonly _cache = new SimpleCache()
|
||||
|
||||
constructor(private readonly _studio: Studio) {
|
||||
this._instantiatePanesAsTheyComeIn()
|
||||
}
|
||||
|
||||
private _instantiatePanesAsTheyComeIn() {
|
||||
const allPanesD = this._getAllPanes()
|
||||
allPanesD.changesWithoutValues().tap(() => {
|
||||
allPanesD.getValue()
|
||||
})
|
||||
}
|
||||
|
||||
private _getAllPanes() {
|
||||
return this._cache.get('_getAllPanels()', () =>
|
||||
prism((): {[instanceId in string]?: PaneInstance<string>} => {
|
||||
const core = val(this._studio.coreP)
|
||||
if (!core) return {}
|
||||
const instanceDescriptors = val(
|
||||
this._studio.atomP.historic.panelInstanceDesceriptors,
|
||||
)
|
||||
const paneClasses = val(
|
||||
this._studio.atomP.ephemeral.extensions.paneClasses,
|
||||
)
|
||||
|
||||
const instances: {[instanceId in string]?: PaneInstance<string>} = {}
|
||||
for (const [, instanceDescriptor] of Object.entries(
|
||||
instanceDescriptors,
|
||||
)) {
|
||||
const panelClass = paneClasses[instanceDescriptor!.paneClass]
|
||||
if (!panelClass) continue
|
||||
const {instanceId} = instanceDescriptor!
|
||||
const {extensionId, classDefinition: definition} = panelClass
|
||||
|
||||
const instance = prism.memo(
|
||||
`instance-${instanceDescriptor!.instanceId}`,
|
||||
() => {
|
||||
const object = this._studio
|
||||
.getExtensionSheet(extensionId, core)
|
||||
.object(
|
||||
'Pane: ' + instanceId,
|
||||
null,
|
||||
core.types.compound({
|
||||
panelThingy: core.types.boolean(false),
|
||||
}),
|
||||
) as $FixMe
|
||||
|
||||
const inst: PaneInstance<$IntentionalAny> = {
|
||||
extensionId,
|
||||
instanceId,
|
||||
object,
|
||||
definition,
|
||||
}
|
||||
return inst
|
||||
},
|
||||
emptyArray,
|
||||
)
|
||||
|
||||
instances[instanceId] = instance
|
||||
}
|
||||
return instances
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
get allPanesD() {
|
||||
return this._getAllPanes()
|
||||
}
|
||||
|
||||
getPanesOfType<PaneClass extends string>(
|
||||
paneClass: PaneClass,
|
||||
): PaneInstance<PaneClass>[] {
|
||||
return []
|
||||
}
|
||||
|
||||
createPane<PaneClass extends string>(
|
||||
paneClass: PaneClass,
|
||||
): PaneInstance<PaneClass> {
|
||||
const core = this._studio.core
|
||||
if (!core) {
|
||||
throw new Error(
|
||||
`Can't create a pane because @theatre/core is not yet loaded`,
|
||||
)
|
||||
}
|
||||
|
||||
const extensionId = val(
|
||||
this._studio.atomP.ephemeral.extensions.paneClasses[paneClass]
|
||||
.extensionId,
|
||||
)
|
||||
|
||||
const allPaneInstances = val(
|
||||
this._studio.atomP.historic.panelInstanceDesceriptors,
|
||||
)
|
||||
let instanceId!: string
|
||||
for (let i = 1; i < 1000; i++) {
|
||||
instanceId = `${paneClass} #${i}`
|
||||
if (!allPaneInstances[instanceId]) break
|
||||
}
|
||||
|
||||
if (!extensionId) {
|
||||
throw new Error(`Pance class "${paneClass}" is not registered.`)
|
||||
}
|
||||
|
||||
this._studio.transaction(({drafts}) => {
|
||||
drafts.historic.panelInstanceDesceriptors[instanceId] = {
|
||||
instanceId,
|
||||
paneClass,
|
||||
}
|
||||
})
|
||||
|
||||
return this._getAllPanes().getValue()[instanceId]!
|
||||
}
|
||||
}
|
|
@ -1,5 +1,4 @@
|
|||
import Scrub from '@theatre/studio/Scrub'
|
||||
import type {FullStudioState} from '@theatre/studio/store'
|
||||
import type {StudioHistoricState} from '@theatre/studio/store/types/historic'
|
||||
import UI from '@theatre/studio/UI'
|
||||
import type {Pointer} from '@theatre/dataverse'
|
||||
|
@ -14,10 +13,14 @@ import TheatreStudio from './TheatreStudio'
|
|||
import {nanoid} from 'nanoid/non-secure'
|
||||
import type Project from '@theatre/core/projects/Project'
|
||||
import type {CoreBits} from '@theatre/core/CoreBundle'
|
||||
import type {privateAPI} from '@theatre/core/privateAPIs'
|
||||
import SimpleCache from '@theatre/shared/utils/SimpleCache'
|
||||
import type {IProject, ISheet} from '@theatre/core'
|
||||
import PaneManager from './PaneManager'
|
||||
import type * as _coreExports from '@theatre/core/coreExports'
|
||||
|
||||
export type CoreExports = typeof _coreExports
|
||||
|
||||
export class Studio {
|
||||
readonly atomP: Pointer<FullStudioState>
|
||||
readonly ui!: UI
|
||||
readonly publicApi: IStudio
|
||||
readonly address: {studioId: string}
|
||||
|
@ -28,23 +31,27 @@ export class Studio {
|
|||
this._projectsProxy.pointer
|
||||
|
||||
private readonly _store = new StudioStore()
|
||||
private _corePrivateApi: typeof privateAPI | undefined
|
||||
private _corePrivateApi: CoreBits['privateAPI'] | undefined
|
||||
|
||||
private _extensions: Atom<{byId: Record<string, IExtension>}> = new Atom({
|
||||
byId: {},
|
||||
})
|
||||
readonly extensionsP = this._extensions.pointer.byId
|
||||
private readonly _cache = new SimpleCache()
|
||||
readonly paneManager: PaneManager
|
||||
|
||||
private _coreAtom = new Atom<{core?: CoreExports}>({})
|
||||
|
||||
get atomP() {
|
||||
return this._store.atomP
|
||||
}
|
||||
|
||||
constructor() {
|
||||
this.address = {studioId: nanoid(10)}
|
||||
this.publicApi = new TheatreStudio(this)
|
||||
this.atomP = this._store.atomP
|
||||
|
||||
if (process.env.NODE_ENV !== 'test') {
|
||||
this.ui = new UI(this)
|
||||
}
|
||||
|
||||
this._attachToIncomingProjects()
|
||||
this.paneManager = new PaneManager(this)
|
||||
}
|
||||
|
||||
get initialized() {
|
||||
|
@ -69,6 +76,7 @@ export class Studio {
|
|||
|
||||
setCoreBits(coreBits: CoreBits) {
|
||||
this._corePrivateApi = coreBits.privateAPI
|
||||
this._coreAtom.setIn(['core'], coreBits.coreExports)
|
||||
this._setProjectsP(coreBits.projectsP)
|
||||
}
|
||||
|
||||
|
@ -96,6 +104,14 @@ export class Studio {
|
|||
return this._corePrivateApi
|
||||
}
|
||||
|
||||
get core() {
|
||||
return this._coreAtom.getState().core
|
||||
}
|
||||
|
||||
get coreP() {
|
||||
return this._coreAtom.pointer.core
|
||||
}
|
||||
|
||||
extend(extension: IExtension) {
|
||||
if (!extension || typeof extension !== 'object') {
|
||||
throw new Error(`Extensions must be JS objects`)
|
||||
|
@ -105,12 +121,47 @@ export class Studio {
|
|||
throw new Error(`extension.id must be a string`)
|
||||
}
|
||||
|
||||
if (this._extensions.getState().byId[extension.id]) {
|
||||
throw new Error(
|
||||
`An extension with the id of ${extension.id} already exists`,
|
||||
)
|
||||
}
|
||||
this.transaction(({drafts}) => {
|
||||
if (drafts.ephemeral.extensions.byId[extension.id]) {
|
||||
throw new Error(`Extension id "${extension.id}" is already defined`)
|
||||
}
|
||||
drafts.ephemeral.extensions.byId[extension.id] = extension
|
||||
|
||||
this._extensions.setIn(['byId', extension.id], extension)
|
||||
const allPaneClasses = drafts.ephemeral.extensions.paneClasses
|
||||
|
||||
extension.panes?.forEach((classDefinition) => {
|
||||
if (typeof classDefinition.class !== 'string') {
|
||||
throw new Error(`pane.class must be a string`)
|
||||
}
|
||||
|
||||
if (classDefinition.class.length < 3) {
|
||||
throw new Error(
|
||||
`pane.class should be a string with 3 or more characters`,
|
||||
)
|
||||
}
|
||||
|
||||
const existing = allPaneClasses[classDefinition.class]
|
||||
if (existing) {
|
||||
throw new Error(
|
||||
`Pane class "${classDefinition.class}" already exists and is supplied by extension ${existing}`,
|
||||
)
|
||||
}
|
||||
|
||||
allPaneClasses[classDefinition.class] = {
|
||||
extensionId: extension.id,
|
||||
classDefinition: classDefinition,
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
getStudioProject(core: CoreExports): IProject {
|
||||
return this._cache.get('getStudioProject', () => core.getProject('Studio'))
|
||||
}
|
||||
|
||||
getExtensionSheet(extensionId: string, core: CoreExports): ISheet {
|
||||
return this._cache.get('extensionSheet-' + extensionId, () =>
|
||||
this.getStudioProject(core)!.sheet('Extension ' + extensionId),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -78,6 +78,10 @@ export default class StudioStore {
|
|||
}
|
||||
}
|
||||
|
||||
getState(): FullStudioState {
|
||||
return this._reduxStore.getState()
|
||||
}
|
||||
|
||||
/**
|
||||
* This method causes the store to start the history from scratch. This is useful
|
||||
* for testing and development where you want to explicitly provide a state to the
|
||||
|
|
|
@ -3,7 +3,7 @@ import studioTicker from '@theatre/studio/studioTicker'
|
|||
import type {IDerivation, Pointer} from '@theatre/dataverse'
|
||||
import {prism} from '@theatre/dataverse'
|
||||
import SimpleCache from '@theatre/shared/utils/SimpleCache'
|
||||
import type {$IntentionalAny, VoidFn} from '@theatre/shared/utils/types'
|
||||
import type {$FixMe, VoidFn} from '@theatre/shared/utils/types'
|
||||
import type {IScrub} from '@theatre/studio/Scrub'
|
||||
|
||||
import type {Studio} from '@theatre/studio/Studio'
|
||||
|
@ -22,8 +22,10 @@ export interface ITransactionAPI {
|
|||
unset<V>(pointer: Pointer<V>): void
|
||||
}
|
||||
|
||||
export interface IPanelType<DataType extends PropTypeConfig_Compound<{}>> {
|
||||
sheetName: string
|
||||
export interface PaneClassDefinition<
|
||||
DataType extends PropTypeConfig_Compound<{}>,
|
||||
> {
|
||||
class: string
|
||||
dataType: DataType
|
||||
component: React.ComponentType<{
|
||||
id: string
|
||||
|
@ -41,7 +43,16 @@ export type IExtension = {
|
|||
globalToolbar?: {
|
||||
component: React.ComponentType<{}>
|
||||
}
|
||||
panes?: Record<string, IPanelType<$IntentionalAny>>
|
||||
panes?: Array<PaneClassDefinition<$FixMe>>
|
||||
}
|
||||
|
||||
export type PaneInstance<ClassName extends string> = {
|
||||
extensionId: string
|
||||
instanceId: string
|
||||
object: ISheetObject<
|
||||
PropTypeConfig_Compound<{data: $FixMe; visible: PropTypeConfig_Boolean}>
|
||||
>
|
||||
definition: PaneClassDefinition<$FixMe>
|
||||
}
|
||||
|
||||
export interface IStudio {
|
||||
|
@ -63,6 +74,14 @@ export interface IStudio {
|
|||
readonly selection: Array<ISheetObject>
|
||||
|
||||
extend(extension: IExtension): void
|
||||
|
||||
getPanesOfType<PaneClass extends string>(
|
||||
paneClass: PaneClass,
|
||||
): Array<PaneInstance<PaneClass>>
|
||||
|
||||
createPane<PaneClass extends string>(
|
||||
paneClass: PaneClass,
|
||||
): PaneInstance<PaneClass>
|
||||
}
|
||||
|
||||
export default class TheatreStudio implements IStudio {
|
||||
|
@ -138,4 +157,15 @@ export default class TheatreStudio implements IStudio {
|
|||
scrub(): IScrub {
|
||||
return getStudio().scrub()
|
||||
}
|
||||
|
||||
getPanesOfType<PaneClass extends string>(
|
||||
paneClass: PaneClass,
|
||||
): PaneInstance<PaneClass>[] {
|
||||
return getStudio().paneManager.getPanesOfType(paneClass)
|
||||
}
|
||||
createPane<PaneClass extends string>(
|
||||
paneClass: PaneClass,
|
||||
): PaneInstance<PaneClass> {
|
||||
return getStudio().paneManager.createPane(paneClass)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -2,10 +2,21 @@ import OutlinePanel from '@theatre/studio/panels/OutlinePanel/OutlinePanel'
|
|||
import ObjectEditorPanel from '@theatre/studio/panels/ObjectEditorPanel/ObjectEditorPanel'
|
||||
import React from 'react'
|
||||
import SequenceEditorPanel from '@theatre/studio/panels/SequenceEditorPanel/SequenceEditorPanel'
|
||||
import getStudio from '@theatre/studio/getStudio'
|
||||
import {useVal} from '@theatre/dataverse-react'
|
||||
import PaneWrapper from '@theatre/studio/panels/BasePanel/PaneWrapper'
|
||||
|
||||
const PanelsRoot: React.FC = () => {
|
||||
const panes = useVal(getStudio().paneManager.allPanesD)
|
||||
const paneEls = Object.entries(panes).map(([instanceId, paneInstance]) => {
|
||||
return (
|
||||
<PaneWrapper key={`pane-${instanceId}`} paneInstance={paneInstance!} />
|
||||
)
|
||||
})
|
||||
|
||||
return (
|
||||
<>
|
||||
{paneEls}
|
||||
<OutlinePanel />
|
||||
<ObjectEditorPanel />
|
||||
<SequenceEditorPanel />
|
||||
|
|
|
@ -2,7 +2,7 @@ import {val} from '@theatre/dataverse'
|
|||
import {usePrism} from '@theatre/dataverse-react'
|
||||
import type {$IntentionalAny} from '@theatre/shared/utils/types'
|
||||
import getStudio from '@theatre/studio/getStudio'
|
||||
import type {PanelId, PanelPosition} from '@theatre/studio/store/types'
|
||||
import type {PanelPosition} from '@theatre/studio/store/types'
|
||||
import React, {useContext} from 'react'
|
||||
import useWindowSize from 'react-use/esm/useWindowSize'
|
||||
import styled from 'styled-components'
|
||||
|
@ -15,7 +15,7 @@ const Container = styled.div`
|
|||
`
|
||||
|
||||
type PanelStuff = {
|
||||
panelId: PanelId
|
||||
panelId: string
|
||||
dims: {
|
||||
width: number
|
||||
height: number
|
||||
|
@ -69,7 +69,7 @@ const PanelContext = React.createContext<PanelStuff>(null as $IntentionalAny)
|
|||
export const usePanel = () => useContext(PanelContext)
|
||||
|
||||
const BasePanel: React.FC<{
|
||||
panelId: PanelId
|
||||
panelId: string
|
||||
defaultPosition: PanelPosition
|
||||
minDims: {width: number; height: number}
|
||||
}> = ({panelId, children, defaultPosition, minDims}) => {
|
||||
|
|
71
theatre/studio/src/panels/BasePanel/PaneWrapper.tsx
Normal file
71
theatre/studio/src/panels/BasePanel/PaneWrapper.tsx
Normal file
|
@ -0,0 +1,71 @@
|
|||
import type {$FixMe} from '@theatre/shared/utils/types'
|
||||
import type {PanelPosition} from '@theatre/studio/store/types'
|
||||
import type {PaneInstance} from '@theatre/studio/TheatreStudio'
|
||||
import React from 'react'
|
||||
import styled from 'styled-components'
|
||||
import {
|
||||
F1,
|
||||
F2 as F2Impl,
|
||||
} from '@theatre/studio/panels/ObjectEditorPanel/ObjectEditorPanel'
|
||||
import BasePanel from './BasePanel'
|
||||
import PanelDragZone from './PanelDragZone'
|
||||
import PanelWrapper from './PanelWrapper'
|
||||
|
||||
const defaultPosition: PanelPosition = {
|
||||
edges: {
|
||||
left: {from: 'screenLeft', distance: 0.3},
|
||||
right: {from: 'screenRight', distance: 0.3},
|
||||
top: {from: 'screenTop', distance: 0.3},
|
||||
bottom: {from: 'screenBottom', distance: 0.3},
|
||||
},
|
||||
}
|
||||
|
||||
const minDims = {width: 300, height: 300}
|
||||
|
||||
const PaneWrapper: React.FC<{
|
||||
paneInstance: PaneInstance<$FixMe>
|
||||
}> = ({paneInstance}) => {
|
||||
return (
|
||||
<BasePanel
|
||||
panelId={`pane-${paneInstance.instanceId}`}
|
||||
defaultPosition={defaultPosition}
|
||||
minDims={minDims}
|
||||
>
|
||||
<Content paneInstance={paneInstance} />
|
||||
</BasePanel>
|
||||
)
|
||||
}
|
||||
|
||||
const Container = styled(PanelWrapper)`
|
||||
overflow-y: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
`
|
||||
|
||||
const Title = styled.div`
|
||||
width: 100%;
|
||||
`
|
||||
|
||||
const F2 = styled(F2Impl)`
|
||||
position: relative;
|
||||
`
|
||||
|
||||
const Content: React.FC<{paneInstance: PaneInstance<$FixMe>}> = ({
|
||||
paneInstance,
|
||||
}) => {
|
||||
const Comp = paneInstance.definition.component
|
||||
return (
|
||||
<Container>
|
||||
<PanelDragZone>
|
||||
<F1>
|
||||
<Title>{paneInstance.instanceId}</Title>
|
||||
</F1>
|
||||
</PanelDragZone>
|
||||
<F2>
|
||||
<Comp id={paneInstance.instanceId} object={paneInstance.object} />
|
||||
</F2>
|
||||
</Container>
|
||||
)
|
||||
}
|
||||
|
||||
export default PaneWrapper
|
|
@ -29,6 +29,7 @@ const initialState: StudioState = {
|
|||
},
|
||||
autoKey: true,
|
||||
coreByProject: {},
|
||||
panelInstanceDesceriptors: {},
|
||||
},
|
||||
ephemeral: {
|
||||
initialised: false,
|
||||
|
@ -36,6 +37,10 @@ const initialState: StudioState = {
|
|||
projects: {
|
||||
stateByProjectId: {},
|
||||
},
|
||||
extensions: {
|
||||
byId: {},
|
||||
paneClasses: {},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
|
|
|
@ -29,7 +29,6 @@ import {graphEditorColors} from '@theatre/studio/panels/SequenceEditorPanel/Grap
|
|||
import type {
|
||||
OutlineSelectable,
|
||||
OutlineSelectionState,
|
||||
PanelId,
|
||||
PanelPosition,
|
||||
} from './types'
|
||||
import {uniq} from 'lodash-es'
|
||||
|
@ -67,7 +66,7 @@ namespace stateEditors {
|
|||
export namespace historic {
|
||||
export namespace panelPositions {
|
||||
export function setPanelPosition(p: {
|
||||
panelId: PanelId
|
||||
panelId: string
|
||||
position: PanelPosition
|
||||
}) {
|
||||
const h = drafts().historic
|
||||
|
|
|
@ -1,5 +1,13 @@
|
|||
import type {ProjectState} from '@theatre/core/projects/store/storeTypes'
|
||||
import type {SerializableMap, StrictRecord} from '@theatre/shared/utils/types'
|
||||
import type {
|
||||
$IntentionalAny,
|
||||
SerializableMap,
|
||||
StrictRecord,
|
||||
} from '@theatre/shared/utils/types'
|
||||
import type {
|
||||
IExtension,
|
||||
PaneClassDefinition,
|
||||
} from '@theatre/studio/TheatreStudio'
|
||||
|
||||
export type StudioEphemeralState = {
|
||||
initialised: boolean
|
||||
|
@ -22,4 +30,13 @@ export type StudioEphemeralState = {
|
|||
}
|
||||
>
|
||||
}
|
||||
extensions: {
|
||||
byId: {[extensionId in string]?: IExtension}
|
||||
paneClasses: {
|
||||
[paneClassName in string]?: {
|
||||
extensionId: string
|
||||
classDefinition: PaneClassDefinition<$IntentionalAny>
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -56,6 +56,11 @@ export type OutlineSelectionState =
|
|||
export type OutlineSelectable = Project | Sheet | SheetObject
|
||||
export type OutlineSelection = OutlineSelectable[]
|
||||
|
||||
export type PanelInstanceDescriptor = {
|
||||
instanceId: string
|
||||
paneClass: string
|
||||
}
|
||||
|
||||
export type StudioHistoricState = {
|
||||
projects: {
|
||||
stateByProjectId: StrictRecord<
|
||||
|
@ -77,7 +82,10 @@ export type StudioHistoricState = {
|
|||
>
|
||||
}
|
||||
panels?: Panels
|
||||
panelPositions?: {[panelId in PanelId]?: PanelPosition}
|
||||
panelPositions?: {[panelIdOrPaneId in string]?: PanelPosition}
|
||||
panelInstanceDesceriptors: {
|
||||
[instanceId in string]?: PanelInstanceDescriptor
|
||||
}
|
||||
autoKey: boolean
|
||||
coreByProject: {[projectId in string]: ProjectState_Historic}
|
||||
}
|
||||
|
|
|
@ -17,9 +17,10 @@ const Container = styled.div`
|
|||
|
||||
const GlobalToolbar: React.FC<{}> = (props) => {
|
||||
const groups: Array<React.ReactNode> = []
|
||||
const extensions = useVal(getStudio().extensionsP)
|
||||
const extensionsById = useVal(getStudio().atomP.ephemeral.extensions.byId)
|
||||
|
||||
for (const [, extension] of Object.entries(extensions)) {
|
||||
for (const [, extension] of Object.entries(extensionsById)) {
|
||||
if (!extension) continue
|
||||
if (extension.globalToolbar) {
|
||||
groups.push(
|
||||
<extension.globalToolbar.component
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue