mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-02-13 12:55:00 +00:00
init: the first public commit for AFFiNE
This commit is contained in:
550
libs/components/board-draw/src/TlDraw.tsx
Normal file
550
libs/components/board-draw/src/TlDraw.tsx
Normal file
@@ -0,0 +1,550 @@
|
||||
/* eslint-disable max-lines */
|
||||
import * as React from 'react';
|
||||
import { Renderer } from '@tldraw/core';
|
||||
import { styled } from '@toeverything/components/ui';
|
||||
import {
|
||||
TDDocument,
|
||||
TDStatus,
|
||||
GRID_SIZE,
|
||||
TDMeta,
|
||||
} from '@toeverything/components/board-types';
|
||||
import {
|
||||
TldrawApp,
|
||||
TldrawAppCtorProps,
|
||||
TLDR,
|
||||
} from '@toeverything/components/board-state';
|
||||
import {
|
||||
TldrawContext,
|
||||
useStylesheet,
|
||||
useKeyboardShortcuts,
|
||||
useTldrawApp,
|
||||
} from './hooks';
|
||||
import { shapeUtils } from '@toeverything/components/board-shapes';
|
||||
import { ToolsPanel } from './components/tools-panel';
|
||||
// import { TopPanel } from '~components/TopPanel';
|
||||
import { ContextMenu } from './components/context-menu';
|
||||
// import { FocusButton } from '~components/FocusButton';
|
||||
import { Loading } from './components/loading';
|
||||
import { ErrorBoundary } from 'react-error-boundary';
|
||||
import { ErrorFallback } from './components/error-fallback';
|
||||
import { ZoomBar } from './components/zoom-bar';
|
||||
import { CommandPanel } from './components/command-panel';
|
||||
|
||||
export interface TldrawProps extends TldrawAppCtorProps {
|
||||
/**
|
||||
* (optional) If provided, the component will load / persist state under this key.
|
||||
*/
|
||||
id?: string;
|
||||
|
||||
/**
|
||||
* (optional) The document to load or update from.
|
||||
*/
|
||||
document?: TDDocument;
|
||||
|
||||
/**
|
||||
* (optional) The current page id.
|
||||
*/
|
||||
currentPageId?: string;
|
||||
|
||||
/**
|
||||
* (optional) Whether the editor should immediately receive focus. Defaults to true.
|
||||
*/
|
||||
autofocus?: boolean;
|
||||
|
||||
/**
|
||||
* (optional) Whether to show the menu UI.
|
||||
*/
|
||||
showMenu?: boolean;
|
||||
|
||||
/**
|
||||
* (optional) Whether to show the multiplayer menu.
|
||||
*/
|
||||
showMultiplayerMenu?: boolean;
|
||||
/**
|
||||
* (optional) Whether to show the pages UI.
|
||||
*/
|
||||
showPages?: boolean;
|
||||
|
||||
/**
|
||||
* (optional) Whether to show the styles UI.
|
||||
*/
|
||||
showStyles?: boolean;
|
||||
|
||||
/**
|
||||
* (optional) Whether to show the zoom UI.
|
||||
*/
|
||||
showZoom?: boolean;
|
||||
|
||||
/**
|
||||
* (optional) Whether to show the tools UI.
|
||||
*/
|
||||
showTools?: boolean;
|
||||
|
||||
/**
|
||||
* (optional) Whether to show a sponsor link for Tldraw.
|
||||
*/
|
||||
showSponsorLink?: boolean;
|
||||
|
||||
/**
|
||||
* (optional) Whether to show the UI.
|
||||
*/
|
||||
showUI?: boolean;
|
||||
|
||||
/**
|
||||
* (optional) Whether to the document should be read only.
|
||||
*/
|
||||
readOnly?: boolean;
|
||||
|
||||
/**
|
||||
* (optional) Whether to to show the app's dark mode UI.
|
||||
*/
|
||||
darkMode?: boolean;
|
||||
|
||||
/**
|
||||
* (optional) If provided, image/video componnets will be disabled.
|
||||
*
|
||||
* Warning: Keeping this enabled for multiplayer applications without provifing a storage
|
||||
* bucket based solution will cause massive base64 string to be written to the liveblocks room.
|
||||
*/
|
||||
disableAssets?: boolean;
|
||||
}
|
||||
|
||||
export function Tldraw({
|
||||
id,
|
||||
document,
|
||||
currentPageId,
|
||||
autofocus = true,
|
||||
showMenu = true,
|
||||
showMultiplayerMenu = true,
|
||||
showPages = true,
|
||||
showTools = true,
|
||||
showZoom = true,
|
||||
showStyles = true,
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
showUI = true,
|
||||
readOnly = false,
|
||||
disableAssets = false,
|
||||
darkMode = false,
|
||||
showSponsorLink,
|
||||
callbacks,
|
||||
commands,
|
||||
getSession,
|
||||
tools,
|
||||
}: TldrawProps) {
|
||||
const [sId, set_sid] = React.useState(id);
|
||||
|
||||
// Create a new app when the component mounts.
|
||||
const [app, setApp] = React.useState(() => {
|
||||
const app = new TldrawApp({
|
||||
id,
|
||||
callbacks,
|
||||
commands,
|
||||
getSession,
|
||||
tools,
|
||||
});
|
||||
return app;
|
||||
});
|
||||
|
||||
// Create a new app if the `id` prop changes.
|
||||
React.useLayoutEffect(() => {
|
||||
if (id === sId) return;
|
||||
const newApp = new TldrawApp({
|
||||
id,
|
||||
callbacks,
|
||||
commands,
|
||||
getSession,
|
||||
tools,
|
||||
});
|
||||
|
||||
set_sid(id);
|
||||
|
||||
setApp(newApp);
|
||||
}, [sId, id]);
|
||||
|
||||
// Update the document if the `document` prop changes but the ids,
|
||||
// are the same, or else load a new document if the ids are different.
|
||||
React.useEffect(() => {
|
||||
if (!document) return;
|
||||
|
||||
if (document.id === app.document.id) {
|
||||
app.updateDocument(document);
|
||||
} else {
|
||||
app.loadDocument(document);
|
||||
}
|
||||
}, [document, app]);
|
||||
|
||||
// Disable assets when the `disableAssets` prop changes.
|
||||
React.useEffect(() => {
|
||||
app.setDisableAssets(disableAssets);
|
||||
}, [app, disableAssets]);
|
||||
|
||||
// Change the page when the `currentPageId` prop changes.
|
||||
React.useEffect(() => {
|
||||
if (!currentPageId) return;
|
||||
app.changePage(currentPageId);
|
||||
}, [currentPageId, app]);
|
||||
|
||||
// Toggle the app's readOnly mode when the `readOnly` prop changes.
|
||||
React.useEffect(() => {
|
||||
app.readOnly = readOnly;
|
||||
}, [app, readOnly]);
|
||||
|
||||
// Toggle the app's darkMode when the `darkMode` prop changes.
|
||||
React.useEffect(() => {
|
||||
if (darkMode !== app.settings.isDarkMode) {
|
||||
app.toggleDarkMode();
|
||||
}
|
||||
}, [app, darkMode]);
|
||||
|
||||
// Update the app's callbacks when any callback changes.
|
||||
React.useEffect(() => {
|
||||
app.callbacks = callbacks || {};
|
||||
}, [app, callbacks]);
|
||||
|
||||
React.useLayoutEffect(() => {
|
||||
if (typeof window === 'undefined') return;
|
||||
if (!window.document?.fonts) return;
|
||||
|
||||
function refreshBoundingBoxes() {
|
||||
app.refreshBoundingBoxes();
|
||||
}
|
||||
window.document.fonts.addEventListener(
|
||||
'loadingdone',
|
||||
refreshBoundingBoxes
|
||||
);
|
||||
return () => {
|
||||
window.document.fonts.removeEventListener(
|
||||
'loadingdone',
|
||||
refreshBoundingBoxes
|
||||
);
|
||||
};
|
||||
}, [app]);
|
||||
|
||||
// Use the `key` to ensure that new selector hooks are made when the id changes
|
||||
return (
|
||||
<TldrawContext.Provider value={app}>
|
||||
<InnerTldraw
|
||||
key={sId || 'Tldraw'}
|
||||
id={sId}
|
||||
autofocus={autofocus}
|
||||
showPages={showPages}
|
||||
showMenu={showMenu}
|
||||
showMultiplayerMenu={showMultiplayerMenu}
|
||||
showStyles={showStyles}
|
||||
showZoom={showZoom}
|
||||
showTools={showTools}
|
||||
showUI={showUI}
|
||||
showSponsorLink={showSponsorLink}
|
||||
readOnly={readOnly}
|
||||
/>
|
||||
</TldrawContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
interface InnerTldrawProps {
|
||||
id?: string;
|
||||
autofocus: boolean;
|
||||
readOnly: boolean;
|
||||
showPages: boolean;
|
||||
showMenu: boolean;
|
||||
showMultiplayerMenu: boolean;
|
||||
showZoom: boolean;
|
||||
showStyles: boolean;
|
||||
showUI: boolean;
|
||||
showTools: boolean;
|
||||
showSponsorLink?: boolean;
|
||||
}
|
||||
|
||||
const InnerTldraw = React.memo(function InnerTldraw({
|
||||
id,
|
||||
autofocus,
|
||||
showPages,
|
||||
showMenu,
|
||||
showMultiplayerMenu,
|
||||
showZoom,
|
||||
showStyles,
|
||||
showTools,
|
||||
showSponsorLink,
|
||||
readOnly,
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
showUI,
|
||||
}: InnerTldrawProps) {
|
||||
const app = useTldrawApp();
|
||||
|
||||
const rWrapper = React.useRef<HTMLDivElement>(null);
|
||||
|
||||
const state = app.useStore();
|
||||
|
||||
const { document, settings, appState, room } = state;
|
||||
const isSelecting = state.appState.activeTool === 'select';
|
||||
|
||||
const page = document.pages[appState.currentPageId];
|
||||
const pageState = document.pageStates[page.id];
|
||||
const assets = document.assets;
|
||||
const { selectedIds } = pageState;
|
||||
|
||||
const isHideBoundsShape =
|
||||
selectedIds.length === 1 &&
|
||||
page.shapes[selectedIds[0]] &&
|
||||
TLDR.get_shape_util(page.shapes[selectedIds[0]].type).hideBounds;
|
||||
|
||||
const isHideResizeHandlesShape =
|
||||
selectedIds.length === 1 &&
|
||||
page.shapes[selectedIds[0]] &&
|
||||
TLDR.get_shape_util(page.shapes[selectedIds[0]].type).hideResizeHandles;
|
||||
|
||||
// Custom rendering meta, with dark mode for shapes
|
||||
const meta: TDMeta = React.useMemo(() => {
|
||||
return { isDarkMode: settings.isDarkMode, app };
|
||||
}, [settings.isDarkMode, app]);
|
||||
|
||||
const showDashedBrush = settings.isCadSelectMode
|
||||
? !appState.selectByContain
|
||||
: appState.selectByContain;
|
||||
|
||||
// Custom theme, based on darkmode
|
||||
const theme = React.useMemo(() => {
|
||||
const { selectByContain } = appState;
|
||||
const { isDarkMode, isCadSelectMode } = settings;
|
||||
|
||||
if (isDarkMode) {
|
||||
const brushBase = isCadSelectMode
|
||||
? selectByContain
|
||||
? '69, 155, 255'
|
||||
: '105, 209, 73'
|
||||
: '180, 180, 180';
|
||||
return {
|
||||
brushFill: `rgba(${brushBase}, ${
|
||||
isCadSelectMode ? 0.08 : 0.05
|
||||
})`,
|
||||
brushStroke: `rgba(${brushBase}, ${
|
||||
isCadSelectMode ? 0.5 : 0.25
|
||||
})`,
|
||||
brushDashStroke: `rgba(${brushBase}, .6)`,
|
||||
selected: 'rgba(38, 150, 255, 1.000)',
|
||||
selectFill: 'rgba(38, 150, 255, 0.05)',
|
||||
background: '#212529',
|
||||
foreground: '#49555f',
|
||||
};
|
||||
}
|
||||
|
||||
const brushBase = isCadSelectMode
|
||||
? selectByContain
|
||||
? '0, 89, 242'
|
||||
: '51, 163, 23'
|
||||
: '0,0,0';
|
||||
|
||||
return {
|
||||
brushFill: `rgba(${brushBase}, ${isCadSelectMode ? 0.08 : 0.05})`,
|
||||
brushStroke: `rgba(${brushBase}, ${isCadSelectMode ? 0.4 : 0.25})`,
|
||||
brushDashStroke: `rgba(${brushBase}, .6)`,
|
||||
background: '#fff',
|
||||
};
|
||||
}, [
|
||||
settings.isDarkMode,
|
||||
settings.isCadSelectMode,
|
||||
appState.selectByContain,
|
||||
]);
|
||||
|
||||
const isInSession = app.session !== undefined;
|
||||
|
||||
// Hide bounds when not using the select tool, or when the only selected shape has handles
|
||||
const hideBounds =
|
||||
(isInSession && app.session?.constructor.name !== 'BrushSession') ||
|
||||
!isSelecting ||
|
||||
isHideBoundsShape ||
|
||||
!!pageState.editingId;
|
||||
|
||||
// Hide bounds when not using the select tool, or when in session
|
||||
const hideHandles = isInSession || !isSelecting;
|
||||
|
||||
// Hide indicators when not using the select tool, or when in session
|
||||
const hideIndicators =
|
||||
(isInSession && state.appState.status !== TDStatus.Brushing) ||
|
||||
!isSelecting;
|
||||
|
||||
const hideCloneHandles =
|
||||
isInSession ||
|
||||
!isSelecting ||
|
||||
!settings.showCloneHandles ||
|
||||
pageState.camera.zoom < 0.2;
|
||||
return (
|
||||
<StyledLayout
|
||||
ref={rWrapper}
|
||||
tabIndex={-0}
|
||||
penColor={app?.appState?.currentStyle?.stroke}
|
||||
>
|
||||
<Loading />
|
||||
<OneOff focusableRef={rWrapper} autofocus={autofocus} />
|
||||
<ContextMenu>
|
||||
<ErrorBoundary FallbackComponent={ErrorFallback}>
|
||||
<Renderer
|
||||
id={id}
|
||||
containerRef={rWrapper}
|
||||
shapeUtils={shapeUtils}
|
||||
page={page}
|
||||
pageState={pageState}
|
||||
assets={assets}
|
||||
snapLines={appState.snapLines}
|
||||
eraseLine={appState.laserLine}
|
||||
grid={GRID_SIZE}
|
||||
users={room?.users}
|
||||
userId={room?.userId}
|
||||
theme={theme}
|
||||
meta={meta as unknown as Record<string, unknown>}
|
||||
hideBounds={hideBounds}
|
||||
hideHandles={hideHandles}
|
||||
hideResizeHandles={isHideResizeHandlesShape}
|
||||
hideIndicators={hideIndicators}
|
||||
hideBindingHandles={!settings.showBindingHandles}
|
||||
hideCloneHandles={hideCloneHandles}
|
||||
hideRotateHandles={!settings.showRotateHandles}
|
||||
hideGrid={!settings.showGrid}
|
||||
showDashedBrush={showDashedBrush}
|
||||
performanceMode={app.session?.performanceMode}
|
||||
onPinchStart={app.onPinchStart}
|
||||
onPinchEnd={app.onPinchEnd}
|
||||
onPinch={app.onPinch}
|
||||
onPan={app.onPan}
|
||||
onZoom={app.onZoom}
|
||||
onPointerDown={app.onPointerDown}
|
||||
onPointerMove={app.onPointerMove}
|
||||
onPointerUp={app.onPointerUp}
|
||||
onPointCanvas={app.onPointCanvas}
|
||||
onDoubleClickCanvas={app.onDoubleClickCanvas}
|
||||
onRightPointCanvas={app.onRightPointCanvas}
|
||||
onDragCanvas={app.onDragCanvas}
|
||||
onReleaseCanvas={app.onReleaseCanvas}
|
||||
onPointShape={app.onPointShape}
|
||||
onDoubleClickShape={app.onDoubleClickShape}
|
||||
onRightPointShape={app.onRightPointShape}
|
||||
onDragShape={app.onDragShape}
|
||||
onHoverShape={app.onHoverShape}
|
||||
onUnhoverShape={app.onUnhoverShape}
|
||||
onReleaseShape={app.onReleaseShape}
|
||||
onPointBounds={app.onPointBounds}
|
||||
onDoubleClickBounds={app.onDoubleClickBounds}
|
||||
onRightPointBounds={app.onRightPointBounds}
|
||||
onDragBounds={app.onDragBounds}
|
||||
onHoverBounds={app.onHoverBounds}
|
||||
onUnhoverBounds={app.onUnhoverBounds}
|
||||
onReleaseBounds={app.onReleaseBounds}
|
||||
onPointBoundsHandle={app.onPointBoundsHandle}
|
||||
onDoubleClickBoundsHandle={
|
||||
app.onDoubleClickBoundsHandle
|
||||
}
|
||||
onRightPointBoundsHandle={app.onRightPointBoundsHandle}
|
||||
onDragBoundsHandle={app.onDragBoundsHandle}
|
||||
onHoverBoundsHandle={app.onHoverBoundsHandle}
|
||||
onUnhoverBoundsHandle={app.onUnhoverBoundsHandle}
|
||||
onReleaseBoundsHandle={app.onReleaseBoundsHandle}
|
||||
onPointHandle={app.onPointHandle}
|
||||
onDoubleClickHandle={app.onDoubleClickHandle}
|
||||
onRightPointHandle={app.onRightPointHandle}
|
||||
onDragHandle={app.onDragHandle}
|
||||
onHoverHandle={app.onHoverHandle}
|
||||
onUnhoverHandle={app.onUnhoverHandle}
|
||||
onReleaseHandle={app.onReleaseHandle}
|
||||
onError={app.onError}
|
||||
onRenderCountChange={app.onRenderCountChange}
|
||||
onShapeChange={app.onShapeChange}
|
||||
onShapeBlur={app.onShapeBlur}
|
||||
onShapeClone={app.onShapeClone}
|
||||
onBoundsChange={app.updateBounds}
|
||||
onKeyDown={app.onKeyDown}
|
||||
onKeyUp={app.onKeyUp}
|
||||
onDragOver={app.onDragOver}
|
||||
onDrop={app.onDrop}
|
||||
/>
|
||||
</ErrorBoundary>
|
||||
</ContextMenu>
|
||||
{showUI && (
|
||||
<StyledUI>
|
||||
<>
|
||||
<StyledSpacer />
|
||||
{showTools && !readOnly && <ToolsPanel app={app} />}
|
||||
<CommandPanel app={app} />
|
||||
</>
|
||||
</StyledUI>
|
||||
)}
|
||||
<ZoomBar />
|
||||
</StyledLayout>
|
||||
);
|
||||
});
|
||||
|
||||
const OneOff = React.memo(function OneOff({
|
||||
focusableRef,
|
||||
autofocus,
|
||||
}: {
|
||||
autofocus?: boolean;
|
||||
focusableRef: React.RefObject<HTMLDivElement>;
|
||||
}) {
|
||||
useKeyboardShortcuts(focusableRef);
|
||||
useStylesheet();
|
||||
|
||||
React.useEffect(() => {
|
||||
if (autofocus) {
|
||||
focusableRef.current?.focus();
|
||||
}
|
||||
}, [autofocus]);
|
||||
|
||||
return null;
|
||||
});
|
||||
|
||||
const StyledLayout = styled('div')<{ penColor: string }>(
|
||||
({ theme, penColor }) => {
|
||||
return {
|
||||
position: 'relative',
|
||||
height: '100%',
|
||||
width: '100vw',
|
||||
minHeight: 0,
|
||||
minWidth: 0,
|
||||
maxHeight: '100%',
|
||||
maxWidth: '100%',
|
||||
overflow: 'hidden',
|
||||
boxSizing: 'border-box',
|
||||
outline: 'none',
|
||||
|
||||
'& .tl-container': {
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
height: '100%',
|
||||
width: '100%',
|
||||
zIndex: 1,
|
||||
'.tl-erase-line': {
|
||||
fill: penColor,
|
||||
},
|
||||
},
|
||||
|
||||
'& input, textarea, button, select, label, button': {
|
||||
webkitTouchCallout: 'none',
|
||||
webkitUserSelect: 'none',
|
||||
WebkitTapHighlightColor: 'transparent',
|
||||
tapHighlightColor: 'transparent',
|
||||
},
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
const StyledUI = styled('div')({
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
height: '100%',
|
||||
width: '100%',
|
||||
padding: '8px 8px 0 8px',
|
||||
display: 'flex',
|
||||
alignItems: 'flex-start',
|
||||
justifyContent: 'flex-start',
|
||||
pointerEvents: 'none',
|
||||
zIndex: 2,
|
||||
'& > *': {
|
||||
pointerEvents: 'all',
|
||||
},
|
||||
});
|
||||
|
||||
const StyledSpacer = styled('div')({
|
||||
flexGrow: 2,
|
||||
});
|
||||
@@ -0,0 +1,70 @@
|
||||
import type { FC } from 'react';
|
||||
import type { TldrawApp } from '@toeverything/components/board-state';
|
||||
import type { TDShape } from '@toeverything/components/board-types';
|
||||
import { Popover, Tooltip, IconButton } from '@toeverything/components/ui';
|
||||
import {
|
||||
BorderColorNoneIcon,
|
||||
BorderColorDuotoneIcon,
|
||||
} from '@toeverything/components/icons';
|
||||
import { countBy, maxBy } from '@toeverything/utils';
|
||||
import { getShapeIds } from './utils';
|
||||
import { Palette } from '../palette';
|
||||
|
||||
interface BorderColorConfigProps {
|
||||
app: TldrawApp;
|
||||
shapes: TDShape[];
|
||||
}
|
||||
|
||||
const _colors = [
|
||||
'none',
|
||||
'#F1675E',
|
||||
'#FF7F22',
|
||||
'#FFCB45',
|
||||
'#40DF9B',
|
||||
'#13D9E3',
|
||||
'#3E6FDB',
|
||||
'#7352F1',
|
||||
'#3A4C5C',
|
||||
'#FFFFFF',
|
||||
];
|
||||
|
||||
const _getIconRenderColor = (shapes: TDShape[]) => {
|
||||
const counted = countBy(shapes, shape => shape.style.stroke);
|
||||
const max = maxBy(Object.entries(counted), ([c, n]) => n);
|
||||
return max[0];
|
||||
};
|
||||
|
||||
export const BorderColorConfig: FC<BorderColorConfigProps> = ({
|
||||
app,
|
||||
shapes,
|
||||
}) => {
|
||||
const setBorderColor = (color: string) => {
|
||||
app.style({ stroke: color }, getShapeIds(shapes));
|
||||
};
|
||||
|
||||
const iconColor = _getIconRenderColor(shapes);
|
||||
|
||||
return (
|
||||
<Popover
|
||||
trigger="hover"
|
||||
placement="bottom-start"
|
||||
content={
|
||||
<Palette
|
||||
colors={_colors}
|
||||
selected={iconColor}
|
||||
onSelect={setBorderColor}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<Tooltip content="Border Color" placement="top-start">
|
||||
<IconButton>
|
||||
{iconColor === 'none' ? (
|
||||
<BorderColorNoneIcon />
|
||||
) : (
|
||||
<BorderColorDuotoneIcon style={{ color: iconColor }} />
|
||||
)}
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</Popover>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,105 @@
|
||||
import type { FC } from 'react';
|
||||
import { Fragment } from 'react';
|
||||
import { TldrawApp, TLDR } from '@toeverything/components/board-state';
|
||||
import { Popover, styled, Divider } from '@toeverything/components/ui';
|
||||
import { getAnchor, useConfig } from './utils';
|
||||
import { BorderColorConfig } from './BorderColorConfig';
|
||||
import { FillColorConfig } from './FillColorConfig';
|
||||
import { FontSizeConfig } from './FontSizeConfig';
|
||||
import { StrokeLineStyleConfig } from './stroke-line-style-config';
|
||||
import { Group, UnGroup } from './GroupOperation';
|
||||
import { DeleteShapes } from './DeleteOperation';
|
||||
|
||||
export const CommandPanel: FC<{ app: TldrawApp }> = ({ app }) => {
|
||||
const state = app.useStore();
|
||||
const bounds = TLDR.get_selected_bounds(state);
|
||||
const point = bounds
|
||||
? app.getScreenPoint([bounds.minX, bounds.minY])
|
||||
: undefined;
|
||||
|
||||
const anchor = getAnchor({
|
||||
x: point?.[0] || 0,
|
||||
y: (point?.[1] || 0) + 40,
|
||||
width: bounds?.width || 0,
|
||||
height: bounds?.height || 0,
|
||||
});
|
||||
|
||||
const config = useConfig(app);
|
||||
|
||||
const configNodes = {
|
||||
stroke: config.stroke.selectedShapes.length ? (
|
||||
<Fragment key="stroke">
|
||||
<StrokeLineStyleConfig
|
||||
app={app}
|
||||
shapes={config.stroke.selectedShapes}
|
||||
/>
|
||||
<BorderColorConfig
|
||||
app={app}
|
||||
shapes={config.stroke.selectedShapes}
|
||||
/>
|
||||
</Fragment>
|
||||
) : null,
|
||||
fill: config.fill.selectedShapes.length ? (
|
||||
<FillColorConfig
|
||||
key="fill"
|
||||
app={app}
|
||||
shapes={config.fill.selectedShapes}
|
||||
/>
|
||||
) : null,
|
||||
font: config.font.selectedShapes.length ? (
|
||||
<FontSizeConfig
|
||||
key="font"
|
||||
app={app}
|
||||
shapes={config.font.selectedShapes}
|
||||
/>
|
||||
) : null,
|
||||
group: config.group.selectedShapes.length ? (
|
||||
<Group key="group" app={app} shapes={config.group.selectedShapes} />
|
||||
) : null,
|
||||
ungroup: config.ungroup.selectedShapes.length ? (
|
||||
<UnGroup
|
||||
key="ungroup"
|
||||
app={app}
|
||||
shapes={config.ungroup.selectedShapes}
|
||||
/>
|
||||
) : null,
|
||||
delete: (
|
||||
<DeleteShapes
|
||||
key="deleteShapes"
|
||||
app={app}
|
||||
shapes={config.deleteShapes.selectedShapes}
|
||||
/>
|
||||
),
|
||||
};
|
||||
|
||||
const nodes = Object.entries(configNodes).filter(([key, node]) => !!node);
|
||||
|
||||
return nodes.length ? (
|
||||
<Popover
|
||||
trigger="click"
|
||||
visible={!!point}
|
||||
anchor={anchor}
|
||||
popoverDirection="none"
|
||||
content={
|
||||
<PopoverContainer>
|
||||
{nodes.map(([key, node], idx, arr) => {
|
||||
return (
|
||||
<Fragment key={key}>
|
||||
{node}
|
||||
{idx < arr.length - 1 ? (
|
||||
<div>
|
||||
<Divider orientation="vertical" />
|
||||
</div>
|
||||
) : null}
|
||||
</Fragment>
|
||||
);
|
||||
})}
|
||||
</PopoverContainer>
|
||||
}
|
||||
/>
|
||||
) : null;
|
||||
};
|
||||
|
||||
const PopoverContainer = styled('div')({
|
||||
display: 'flex',
|
||||
});
|
||||
@@ -0,0 +1,24 @@
|
||||
import type { FC } from 'react';
|
||||
import type { TldrawApp } from '@toeverything/components/board-state';
|
||||
import type { TDShape } from '@toeverything/components/board-types';
|
||||
import { IconButton, Tooltip } from '@toeverything/components/ui';
|
||||
import { DeleteCashBinIcon } from '@toeverything/components/icons';
|
||||
import { getShapeIds } from './utils';
|
||||
|
||||
interface DeleteShapesProps {
|
||||
app: TldrawApp;
|
||||
shapes: TDShape[];
|
||||
}
|
||||
|
||||
export const DeleteShapes: FC<DeleteShapesProps> = ({ app, shapes }) => {
|
||||
const deleteShapes = () => {
|
||||
app.delete(getShapeIds(shapes));
|
||||
};
|
||||
return (
|
||||
<Tooltip content="Delete">
|
||||
<IconButton onClick={deleteShapes}>
|
||||
<DeleteCashBinIcon />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,90 @@
|
||||
import type { FC } from 'react';
|
||||
import type { TldrawApp } from '@toeverything/components/board-state';
|
||||
import type { TDShape } from '@toeverything/components/board-types';
|
||||
import {
|
||||
Popover,
|
||||
Tooltip,
|
||||
IconButton,
|
||||
useTheme,
|
||||
} from '@toeverything/components/ui';
|
||||
import {
|
||||
ShapeColorNoneIcon,
|
||||
ShapeColorDuotoneIcon,
|
||||
} from '@toeverything/components/icons';
|
||||
import { countBy, maxBy } from '@toeverything/utils';
|
||||
import { getShapeIds } from './utils';
|
||||
import { Palette } from '../palette';
|
||||
|
||||
interface BorderColorConfigProps {
|
||||
app: TldrawApp;
|
||||
shapes: TDShape[];
|
||||
}
|
||||
|
||||
type ColorType = 'none' | string;
|
||||
|
||||
const _colors: ColorType[] = [
|
||||
'none',
|
||||
'#F1675E',
|
||||
'#FF7F22',
|
||||
'#FFCB45',
|
||||
'#40DF9B',
|
||||
'#13D9E3',
|
||||
'#3E6FDB',
|
||||
'#7352F1',
|
||||
'#3A4C5C',
|
||||
'#FFFFFF',
|
||||
];
|
||||
|
||||
const _getIconRenderColor = (shapes: TDShape[]) => {
|
||||
const counted = countBy(shapes, shape => shape.style.fill);
|
||||
const max = maxBy(Object.entries(counted), ([c, n]) => n);
|
||||
return max[0];
|
||||
};
|
||||
|
||||
export const FillColorConfig: FC<BorderColorConfigProps> = ({
|
||||
app,
|
||||
shapes,
|
||||
}) => {
|
||||
const theme = useTheme();
|
||||
const setFillColor = (color: ColorType) => {
|
||||
app.style(
|
||||
{ fill: color, isFilled: color !== 'none' },
|
||||
getShapeIds(shapes)
|
||||
);
|
||||
};
|
||||
|
||||
const iconColor = _getIconRenderColor(shapes);
|
||||
|
||||
return (
|
||||
<Popover
|
||||
trigger="hover"
|
||||
placement="bottom-start"
|
||||
content={
|
||||
<Palette
|
||||
colors={_colors}
|
||||
selected={iconColor}
|
||||
onSelect={setFillColor}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<Tooltip content="Fill Color" placement="top-start">
|
||||
<IconButton>
|
||||
{iconColor === 'none' ? (
|
||||
<ShapeColorNoneIcon />
|
||||
) : (
|
||||
<ShapeColorDuotoneIcon
|
||||
style={{
|
||||
color: iconColor,
|
||||
border:
|
||||
iconColor === '#FFFFFF'
|
||||
? `1px solid ${theme.affine.palette.tagHover}`
|
||||
: 0,
|
||||
borderRadius: '5px',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</Popover>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,107 @@
|
||||
import type { FC } from 'react';
|
||||
import type { TldrawApp } from '@toeverything/components/board-state';
|
||||
import type { TDShape } from '@toeverything/components/board-types';
|
||||
import { FontSizeStyle } from '@toeverything/components/board-types';
|
||||
import {
|
||||
Popover,
|
||||
Tooltip,
|
||||
IconButton,
|
||||
styled,
|
||||
} from '@toeverything/components/ui';
|
||||
import {
|
||||
TextFontIcon,
|
||||
HeadingOneIcon,
|
||||
HeadingTwoIcon,
|
||||
HeadingThreeIcon,
|
||||
} from '@toeverything/components/icons';
|
||||
import { countBy, maxBy } from '@toeverything/utils';
|
||||
import { getShapeIds } from './utils';
|
||||
|
||||
interface FontSizeConfigProps {
|
||||
app: TldrawApp;
|
||||
shapes: TDShape[];
|
||||
}
|
||||
|
||||
const _fontSizes = [
|
||||
{
|
||||
name: 'Heading 1',
|
||||
value: FontSizeStyle.h1,
|
||||
icon: <HeadingOneIcon />,
|
||||
},
|
||||
{
|
||||
name: 'Heading 2',
|
||||
value: FontSizeStyle.h2,
|
||||
icon: <HeadingTwoIcon />,
|
||||
},
|
||||
{
|
||||
name: 'Heading 3',
|
||||
value: FontSizeStyle.h3,
|
||||
icon: <HeadingThreeIcon />,
|
||||
},
|
||||
{
|
||||
name: 'Text',
|
||||
value: FontSizeStyle.body,
|
||||
icon: <TextFontIcon />,
|
||||
},
|
||||
];
|
||||
|
||||
const _getFontSize = (shapes: TDShape[]): FontSizeStyle => {
|
||||
const counted = countBy(shapes, shape => shape.style.fill);
|
||||
const max = maxBy(Object.entries(counted), ([c, n]) => n);
|
||||
return max[0] as unknown as FontSizeStyle;
|
||||
};
|
||||
|
||||
export const FontSizeConfig: FC<FontSizeConfigProps> = ({ app, shapes }) => {
|
||||
const setFontSize = (size: FontSizeStyle) => {
|
||||
app.style({ fontSize: size }, getShapeIds(shapes));
|
||||
};
|
||||
|
||||
const fontSize = _getFontSize(shapes);
|
||||
const selected =
|
||||
_fontSizes.find(f => f.value === fontSize) || _fontSizes[3];
|
||||
|
||||
return (
|
||||
<Popover
|
||||
trigger="hover"
|
||||
placement="bottom-start"
|
||||
content={
|
||||
<div>
|
||||
{_fontSizes.map(fontSize => {
|
||||
return (
|
||||
<ListItemContainer
|
||||
key={fontSize.value}
|
||||
onClick={() => setFontSize(fontSize.value)}
|
||||
>
|
||||
{fontSize.icon}
|
||||
<ListItemTitle>{fontSize.name}</ListItemTitle>
|
||||
</ListItemContainer>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<Tooltip content="Font Size" placement="top-start">
|
||||
<IconButton>{selected.icon}</IconButton>
|
||||
</Tooltip>
|
||||
</Popover>
|
||||
);
|
||||
};
|
||||
|
||||
const ListItemContainer = styled('div')(({ theme }) => ({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
cursor: 'pointer',
|
||||
height: '32px',
|
||||
padding: '4px 12px',
|
||||
color: theme.affine.palette.icons,
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
'&:hover': {
|
||||
backgroundColor: theme.affine.palette.hover,
|
||||
},
|
||||
}));
|
||||
|
||||
const ListItemTitle = styled('span')(({ theme }) => ({
|
||||
marginLeft: '12px',
|
||||
color: theme.affine.palette.primaryText,
|
||||
}));
|
||||
@@ -0,0 +1,38 @@
|
||||
import type { FC } from 'react';
|
||||
import type { TldrawApp } from '@toeverything/components/board-state';
|
||||
import type { TDShape } from '@toeverything/components/board-types';
|
||||
import { IconButton, Tooltip } from '@toeverything/components/ui';
|
||||
import { GroupIcon, UngroupIcon } from '@toeverything/components/icons';
|
||||
import { getShapeIds } from './utils';
|
||||
|
||||
interface GroupAndUnGroupProps {
|
||||
app: TldrawApp;
|
||||
shapes: TDShape[];
|
||||
}
|
||||
|
||||
export const Group: FC<GroupAndUnGroupProps> = ({ app, shapes }) => {
|
||||
const group = () => {
|
||||
app.group(getShapeIds(shapes));
|
||||
};
|
||||
return (
|
||||
<Tooltip content="Group">
|
||||
<IconButton onClick={group}>
|
||||
<GroupIcon />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
);
|
||||
};
|
||||
|
||||
export const UnGroup: FC<GroupAndUnGroupProps> = ({ app, shapes }) => {
|
||||
const ungroup = () => {
|
||||
app.ungroup(getShapeIds(shapes));
|
||||
};
|
||||
|
||||
return (
|
||||
<Tooltip content="Ungroup">
|
||||
<IconButton onClick={ungroup}>
|
||||
<UngroupIcon />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
export { CommandPanel } from './CommandPanel';
|
||||
@@ -0,0 +1,110 @@
|
||||
import type { FC } from 'react';
|
||||
import { DashStyle, StrokeWidth } from '@toeverything/components/board-types';
|
||||
import {
|
||||
LineNoneIcon,
|
||||
DashLineIcon,
|
||||
SolidLineIcon,
|
||||
BrushIcon,
|
||||
} from '@toeverything/components/icons';
|
||||
import {
|
||||
IconButton,
|
||||
styled,
|
||||
Tooltip,
|
||||
Slider,
|
||||
} from '@toeverything/components/ui';
|
||||
|
||||
export const lineStyles = [
|
||||
{
|
||||
name: 'None',
|
||||
value: DashStyle.None,
|
||||
icon: <LineNoneIcon />,
|
||||
},
|
||||
{
|
||||
name: 'Draw',
|
||||
value: DashStyle.Draw,
|
||||
icon: <BrushIcon />,
|
||||
},
|
||||
{
|
||||
name: 'Solid',
|
||||
value: DashStyle.Solid,
|
||||
icon: <SolidLineIcon />,
|
||||
},
|
||||
{
|
||||
name: 'Dash',
|
||||
value: DashStyle.Dashed,
|
||||
icon: <DashLineIcon />,
|
||||
},
|
||||
];
|
||||
|
||||
interface LineStyleProps {
|
||||
strokeStyle: DashStyle;
|
||||
onStrokeStyleChange: (style: DashStyle) => void;
|
||||
|
||||
strokeWidth: StrokeWidth;
|
||||
onStrokeWidthChange: (width: StrokeWidth) => void;
|
||||
}
|
||||
|
||||
export const LineStyle: FC<LineStyleProps> = ({
|
||||
strokeStyle,
|
||||
onStrokeStyleChange,
|
||||
strokeWidth,
|
||||
onStrokeWidthChange,
|
||||
}) => {
|
||||
return (
|
||||
<Container>
|
||||
<Title>Stroke Style</Title>
|
||||
<StrokeStyleContainer>
|
||||
{lineStyles.map(lineStyle => {
|
||||
const active = lineStyle.value === strokeStyle;
|
||||
return (
|
||||
<Tooltip key={lineStyle.value} content={lineStyle.name}>
|
||||
<IconButton
|
||||
className={active ? 'hover' : ''}
|
||||
onClick={() => {
|
||||
onStrokeStyleChange(lineStyle.value);
|
||||
}}
|
||||
>
|
||||
{lineStyle.icon}
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
);
|
||||
})}
|
||||
</StrokeStyleContainer>
|
||||
<Title>Thickness</Title>
|
||||
<SliderContainer>
|
||||
<Slider
|
||||
value={strokeWidth}
|
||||
marks
|
||||
min={StrokeWidth.s1}
|
||||
max={StrokeWidth.s6}
|
||||
step={2}
|
||||
onChange={(event, value) => {
|
||||
onStrokeWidthChange(value as StrokeWidth);
|
||||
}}
|
||||
/>
|
||||
</SliderContainer>
|
||||
</Container>
|
||||
);
|
||||
};
|
||||
|
||||
const Container = styled('div')({
|
||||
width: '132px',
|
||||
margin: '0 10px',
|
||||
});
|
||||
|
||||
const Title = styled('div')(({ theme }) => ({
|
||||
fontSize: '12px',
|
||||
lineHeight: '14px',
|
||||
color: theme.affine.palette.menu,
|
||||
margin: '6px 0',
|
||||
}));
|
||||
|
||||
const StrokeStyleContainer = styled('div')({
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
});
|
||||
|
||||
const SliderContainer = styled('div')({
|
||||
padding: '0 6px',
|
||||
});
|
||||
@@ -0,0 +1,71 @@
|
||||
import type { FC } from 'react';
|
||||
import type { TldrawApp } from '@toeverything/components/board-state';
|
||||
import { DashStyle, StrokeWidth } from '@toeverything/components/board-types';
|
||||
import type { TDShape } from '@toeverything/components/board-types';
|
||||
import { Popover, IconButton, Tooltip } from '@toeverything/components/ui';
|
||||
import { BrushIcon } from '@toeverything/components/icons';
|
||||
import { countBy, maxBy } from '@toeverything/utils';
|
||||
import { getShapeIds } from '../utils';
|
||||
import { LineStyle, lineStyles } from './LineStyle';
|
||||
|
||||
const _getStrokeStyle = (shapes: TDShape[]): DashStyle => {
|
||||
const counted = countBy(shapes, shape => shape.style.dash);
|
||||
const max = maxBy(Object.entries(counted), ([c, n]) => n);
|
||||
return max[0] as DashStyle;
|
||||
};
|
||||
|
||||
const _getStrokeWidth = (shapes: TDShape[]): StrokeWidth => {
|
||||
const counted = countBy(shapes, shape => shape.style.strokeWidth);
|
||||
const max = maxBy(Object.entries(counted), ([c, n]) => n);
|
||||
return Number(max[0]) as StrokeWidth;
|
||||
};
|
||||
interface BorderColorConfigProps {
|
||||
app: TldrawApp;
|
||||
shapes: TDShape[];
|
||||
}
|
||||
|
||||
export const StrokeLineStyleConfig: FC<BorderColorConfigProps> = ({
|
||||
app,
|
||||
shapes,
|
||||
}) => {
|
||||
const strokeStyle = _getStrokeStyle(shapes);
|
||||
const strokeWidth = _getStrokeWidth(shapes);
|
||||
const setStrokeLineStyle = (style: DashStyle) => {
|
||||
app.style(
|
||||
{
|
||||
dash: style,
|
||||
},
|
||||
getShapeIds(shapes)
|
||||
);
|
||||
};
|
||||
const setStrokeLineWidth = (width: StrokeWidth) => {
|
||||
app.style(
|
||||
{
|
||||
strokeWidth: width,
|
||||
},
|
||||
getShapeIds(shapes)
|
||||
);
|
||||
};
|
||||
|
||||
const icon = lineStyles.find(style => style.value === strokeStyle)
|
||||
?.icon || <BrushIcon />;
|
||||
|
||||
return (
|
||||
<Popover
|
||||
trigger="hover"
|
||||
placement="bottom-start"
|
||||
content={
|
||||
<LineStyle
|
||||
strokeStyle={strokeStyle}
|
||||
onStrokeStyleChange={setStrokeLineStyle}
|
||||
strokeWidth={strokeWidth}
|
||||
onStrokeWidthChange={setStrokeLineWidth}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<Tooltip placement="top-start" content="Stroke Style">
|
||||
<IconButton>{icon}</IconButton>
|
||||
</Tooltip>
|
||||
</Popover>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
export { StrokeLineStyleConfig } from './StrokeLineStyleConfig';
|
||||
@@ -0,0 +1,36 @@
|
||||
interface ShapeRect {
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
export const getAnchor = (rect: ShapeRect) => {
|
||||
return {
|
||||
getBoundingClientRect(): DOMRect {
|
||||
const x = rect.x;
|
||||
const y = rect.y;
|
||||
const width = rect.width;
|
||||
const height = rect.height;
|
||||
|
||||
const calcRect = {
|
||||
x,
|
||||
y,
|
||||
top: y,
|
||||
left: x,
|
||||
width,
|
||||
height,
|
||||
bottom: y + height,
|
||||
right: x + width,
|
||||
};
|
||||
const jsonStr = JSON.stringify(rect);
|
||||
|
||||
return {
|
||||
...calcRect,
|
||||
toJSON() {
|
||||
return jsonStr;
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,2 @@
|
||||
export { getAnchor } from './get-anchor';
|
||||
export { useConfig, getShapeIds } from './use-config';
|
||||
@@ -0,0 +1,101 @@
|
||||
import type { TldrawApp } from '@toeverything/components/board-state';
|
||||
import type { TDShape } from '@toeverything/components/board-types';
|
||||
import { TDShapeType } from '@toeverything/components/board-types';
|
||||
import { TLDR } from '@toeverything/components/board-state';
|
||||
|
||||
interface Config {
|
||||
type: 'stroke' | 'fill' | 'font' | 'group' | 'ungroup' | 'deleteShapes';
|
||||
selectedShapes: TDShape[];
|
||||
}
|
||||
|
||||
const _createInitConfig = (): Record<Config['type'], Config> => {
|
||||
return {
|
||||
fill: {
|
||||
type: 'fill',
|
||||
selectedShapes: [],
|
||||
},
|
||||
stroke: {
|
||||
type: 'stroke',
|
||||
selectedShapes: [],
|
||||
},
|
||||
font: {
|
||||
type: 'font',
|
||||
selectedShapes: [],
|
||||
},
|
||||
group: {
|
||||
type: 'group',
|
||||
selectedShapes: [],
|
||||
},
|
||||
ungroup: {
|
||||
type: 'ungroup',
|
||||
selectedShapes: [],
|
||||
},
|
||||
deleteShapes: {
|
||||
type: 'deleteShapes',
|
||||
selectedShapes: [],
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
const _isSupportStroke = (shape: TDShape): boolean => {
|
||||
return [
|
||||
TDShapeType.Rectangle,
|
||||
TDShapeType.Ellipse,
|
||||
TDShapeType.Hexagon,
|
||||
TDShapeType.Triangle,
|
||||
TDShapeType.WhiteArrow,
|
||||
TDShapeType.Pentagram,
|
||||
TDShapeType.Pencil,
|
||||
TDShapeType.Laser,
|
||||
TDShapeType.Highlight,
|
||||
TDShapeType.Arrow,
|
||||
TDShapeType.Line,
|
||||
].some(type => type === shape.type);
|
||||
};
|
||||
|
||||
const _isSupportFill = (shape: TDShape): boolean => {
|
||||
return [
|
||||
TDShapeType.Rectangle,
|
||||
TDShapeType.Ellipse,
|
||||
TDShapeType.Hexagon,
|
||||
TDShapeType.Triangle,
|
||||
TDShapeType.WhiteArrow,
|
||||
TDShapeType.Pentagram,
|
||||
].some(type => type === shape.type);
|
||||
};
|
||||
|
||||
export const useConfig = (app: TldrawApp): Record<Config['type'], Config> => {
|
||||
const state = app.useStore();
|
||||
const selectedShapes = TLDR.get_selected_shapes(state, app.currentPageId);
|
||||
const config = selectedShapes.reduce<Record<Config['type'], Config>>(
|
||||
(acc, cur) => {
|
||||
if (_isSupportStroke(cur)) {
|
||||
acc.stroke.selectedShapes.push(cur);
|
||||
}
|
||||
if (_isSupportFill(cur)) {
|
||||
acc.fill.selectedShapes.push(cur);
|
||||
acc.font.selectedShapes.push(cur);
|
||||
}
|
||||
return acc;
|
||||
},
|
||||
_createInitConfig()
|
||||
);
|
||||
|
||||
if (
|
||||
selectedShapes.length === 1 &&
|
||||
selectedShapes[0].type === TDShapeType.Group
|
||||
) {
|
||||
config.ungroup.selectedShapes = selectedShapes;
|
||||
}
|
||||
if (selectedShapes.length > 1) {
|
||||
config.group.selectedShapes = selectedShapes;
|
||||
}
|
||||
|
||||
config.deleteShapes.selectedShapes = selectedShapes;
|
||||
|
||||
return config;
|
||||
};
|
||||
|
||||
export const getShapeIds = (shapes?: TDShape[]): string[] => {
|
||||
return (shapes || []).map(shape => shape.id).filter(id => !!id);
|
||||
};
|
||||
@@ -0,0 +1,5 @@
|
||||
import type { FC, ReactNode } from 'react';
|
||||
|
||||
export const ContextMenu: FC<{ children: ReactNode }> = ({ children }) => {
|
||||
return <div>{children}</div>;
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
export * from './context-menu';
|
||||
@@ -0,0 +1,112 @@
|
||||
import * as React from 'react';
|
||||
import { FallbackProps } from 'react-error-boundary';
|
||||
import { useTldrawApp } from '../../hooks';
|
||||
import { styled } from '@toeverything/components/ui';
|
||||
|
||||
export function ErrorFallback({ error, resetErrorBoundary }: FallbackProps) {
|
||||
const app = useTldrawApp();
|
||||
|
||||
const refreshPage = () => {
|
||||
window.location.reload();
|
||||
resetErrorBoundary();
|
||||
};
|
||||
|
||||
const copyError = () => {
|
||||
const textarea = document.createElement('textarea');
|
||||
textarea.value = error.message;
|
||||
document.body.appendChild(textarea);
|
||||
textarea.select();
|
||||
document.execCommand('copy');
|
||||
textarea.remove();
|
||||
};
|
||||
|
||||
const downloadBackup = () => {
|
||||
app.saveProjectAs();
|
||||
};
|
||||
|
||||
const resetDocument = () => {
|
||||
app.resetDocument();
|
||||
resetErrorBoundary();
|
||||
};
|
||||
|
||||
return (
|
||||
<Container>
|
||||
<InnerContainer>
|
||||
<div>We've encountered an error!</div>
|
||||
<pre>
|
||||
<code>{error.message}</code>
|
||||
</pre>
|
||||
<div>
|
||||
<button onClick={copyError}>Copy Error</button>
|
||||
<button onClick={refreshPage}>Refresh Page</button>
|
||||
</div>
|
||||
<hr />
|
||||
<p>
|
||||
Keep getting this error?{' '}
|
||||
<a onClick={downloadBackup} title="Download your project">
|
||||
Download your project
|
||||
</a>{' '}
|
||||
as a backup and then{' '}
|
||||
<a onClick={resetDocument} title="Reset the document">
|
||||
reset the document
|
||||
</a>
|
||||
.
|
||||
</p>
|
||||
</InnerContainer>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
const Container = styled('div')({
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
backgroundColor: '$canvas',
|
||||
});
|
||||
|
||||
const InnerContainer = styled('div')({
|
||||
backgroundColor: '$panel',
|
||||
border: '1px solid $panelContrast',
|
||||
padding: '$5',
|
||||
borderRadius: 8,
|
||||
boxShadow: '$panel',
|
||||
maxWidth: 320,
|
||||
color: '$text',
|
||||
fontFamily: '$ui',
|
||||
fontSize: '$2',
|
||||
textAlign: 'center',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '$3',
|
||||
'& > pre': {
|
||||
marginTop: '$3',
|
||||
marginBottom: '$3',
|
||||
textAlign: 'left',
|
||||
whiteSpace: 'pre-wrap',
|
||||
backgroundColor: '$hover',
|
||||
padding: '$4',
|
||||
borderRadius: '$2',
|
||||
fontFamily: '"Menlo", "Monaco", monospace',
|
||||
fontWeight: 500,
|
||||
},
|
||||
'& p': {
|
||||
fontFamily: '$body',
|
||||
lineHeight: 1.7,
|
||||
padding: '$5',
|
||||
margin: 0,
|
||||
},
|
||||
'& a': {
|
||||
color: '$text',
|
||||
cursor: 'pointer',
|
||||
textDecoration: 'underline',
|
||||
},
|
||||
'& hr': {
|
||||
marginLeft: '-$5',
|
||||
marginRight: '-$5',
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1 @@
|
||||
export * from './error-fallback';
|
||||
@@ -0,0 +1 @@
|
||||
export * from './loading';
|
||||
@@ -0,0 +1,46 @@
|
||||
import * as React from 'react';
|
||||
import { useTldrawApp } from '../../hooks';
|
||||
import { styled } from '@toeverything/components/ui';
|
||||
import type { TDSnapshot } from '@toeverything/components/board-types';
|
||||
|
||||
const loadingSelector = (s: TDSnapshot) => s.appState.isLoading;
|
||||
|
||||
export function Loading() {
|
||||
const app = useTldrawApp();
|
||||
const isLoading = app.useStore(loadingSelector);
|
||||
|
||||
return (
|
||||
<StyledLoadingPanelContainer hidden={!isLoading}>
|
||||
Loading...
|
||||
</StyledLoadingPanelContainer>
|
||||
);
|
||||
}
|
||||
|
||||
const StyledLoadingPanelContainer = styled('div')({
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: '50%',
|
||||
transform: `translate(-50%, 0)`,
|
||||
borderBottomLeftRadius: '12px',
|
||||
borderBottomRightRadius: '12px',
|
||||
padding: '8px 16px',
|
||||
fontFamily: 'var(--fonts-ui)',
|
||||
fontSize: 'var(--fontSizes-1)',
|
||||
boxShadow: 'var(--shadows-panel)',
|
||||
backgroundColor: 'white',
|
||||
zIndex: 200,
|
||||
pointerEvents: 'none',
|
||||
'& > div > *': {
|
||||
pointerEvents: 'all',
|
||||
},
|
||||
variants: {
|
||||
transform: {
|
||||
hidden: {
|
||||
transform: `translate(-50%, 100%)`,
|
||||
},
|
||||
visible: {
|
||||
transform: `translate(-50%, 0%)`,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,95 @@
|
||||
import type { FC, PropsWithChildren } from 'react';
|
||||
import { useMemo } from 'react';
|
||||
import { styled, Tooltip } from '@toeverything/components/ui';
|
||||
import { ShapeColorNoneIcon } from '@toeverything/components/icons';
|
||||
|
||||
interface ColorObject {
|
||||
name?: string;
|
||||
/**
|
||||
* color: none means no color
|
||||
*/
|
||||
color: string;
|
||||
}
|
||||
/**
|
||||
* ColorValue : none means no color
|
||||
*/
|
||||
type ColorValue = string | ColorObject;
|
||||
interface PaletteProps {
|
||||
colors: ColorValue[];
|
||||
selected?: string;
|
||||
onSelect?: (color: string) => void;
|
||||
}
|
||||
|
||||
const formatColors = (colors: ColorValue[]): ColorObject[] => {
|
||||
return colors.map(color => {
|
||||
return typeof color === 'string' ? { color } : color;
|
||||
});
|
||||
};
|
||||
|
||||
export const Palette: FC<PaletteProps> = ({
|
||||
colors: propColors,
|
||||
selected,
|
||||
onSelect,
|
||||
}) => {
|
||||
const colorObjects = useMemo(() => formatColors(propColors), [propColors]);
|
||||
return (
|
||||
<Container>
|
||||
{colorObjects.map(colorObject => {
|
||||
const color = colorObject.color;
|
||||
const selectedThisColor = selected === color;
|
||||
return (
|
||||
<Tooltip key={color} content={colorObject.name}>
|
||||
<SelectableContainer
|
||||
selected={selectedThisColor}
|
||||
onClick={() => {
|
||||
onSelect?.(color);
|
||||
}}
|
||||
>
|
||||
{color === 'none' ? (
|
||||
<StyledShapeColorNoneIcon />
|
||||
) : (
|
||||
<Color style={{ backgroundColor: color }} />
|
||||
)}
|
||||
</SelectableContainer>
|
||||
</Tooltip>
|
||||
);
|
||||
})}
|
||||
</Container>
|
||||
);
|
||||
};
|
||||
|
||||
const Container = styled('div')({
|
||||
width: '120px',
|
||||
display: 'flex',
|
||||
flexWrap: 'wrap',
|
||||
});
|
||||
|
||||
const SelectableContainer = styled('div')<{ selected?: boolean }>(
|
||||
({ selected, theme }) => ({
|
||||
width: '20px',
|
||||
height: '20px',
|
||||
border: `1px solid ${
|
||||
selected ? theme.affine.palette.primary : 'rgba(0,0,0,0)'
|
||||
}`,
|
||||
borderRadius: '5px',
|
||||
overflow: 'hidden',
|
||||
margin: '10px',
|
||||
padding: '1px',
|
||||
cursor: 'pointer',
|
||||
boxSizing: 'border-box',
|
||||
})
|
||||
);
|
||||
|
||||
const Color = styled('div')({
|
||||
width: '16px',
|
||||
height: '16px',
|
||||
borderRadius: '5px',
|
||||
boxShadow: '0px 0px 2px rgba(0, 0, 0, 0.25)',
|
||||
});
|
||||
|
||||
const StyledShapeColorNoneIcon = styled(ShapeColorNoneIcon)(({ theme }) => ({
|
||||
position: 'relative',
|
||||
left: '-4px',
|
||||
top: '-4px',
|
||||
color: theme.affine.palette.icons,
|
||||
}));
|
||||
@@ -0,0 +1 @@
|
||||
export { Palette } from './Palette';
|
||||
@@ -0,0 +1,86 @@
|
||||
import { FC, useState, useEffect } from 'react';
|
||||
import {
|
||||
ConnectorIcon,
|
||||
ConectorLineIcon,
|
||||
ConectorArrowIcon,
|
||||
} from '@toeverything/components/icons';
|
||||
import {
|
||||
Tooltip,
|
||||
Popover,
|
||||
IconButton,
|
||||
styled,
|
||||
} from '@toeverything/components/ui';
|
||||
|
||||
import { TDSnapshot, TDShapeType } from '@toeverything/components/board-types';
|
||||
import { TldrawApp } from '@toeverything/components/board-state';
|
||||
|
||||
export type ShapeTypes = TDShapeType.Line | TDShapeType.Arrow;
|
||||
|
||||
const shapes = [
|
||||
{
|
||||
type: TDShapeType.Line,
|
||||
label: 'Line',
|
||||
tooltip: 'Line',
|
||||
icon: ConectorLineIcon,
|
||||
},
|
||||
{
|
||||
type: TDShapeType.Arrow,
|
||||
label: 'Arrow',
|
||||
tooltip: 'Arrow',
|
||||
icon: ConectorArrowIcon,
|
||||
},
|
||||
] as const;
|
||||
|
||||
const activeToolSelector = (s: TDSnapshot) => s.appState.activeTool;
|
||||
|
||||
export const LineTools: FC<{ app: TldrawApp }> = ({ app }) => {
|
||||
const activeTool = app.useStore(activeToolSelector);
|
||||
|
||||
const [lastActiveTool, setLastActiveTool] = useState<ShapeTypes>(
|
||||
TDShapeType.Line
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
shapes.find(s => s.type === activeTool) &&
|
||||
lastActiveTool !== activeTool
|
||||
) {
|
||||
setLastActiveTool(activeTool as ShapeTypes);
|
||||
}
|
||||
}, [activeTool, lastActiveTool]);
|
||||
|
||||
return (
|
||||
<Popover
|
||||
placement="right-start"
|
||||
trigger="click"
|
||||
content={
|
||||
<ShapesContainer>
|
||||
{shapes.map(({ type, label, tooltip, icon: Icon }) => (
|
||||
<Tooltip content={tooltip} key={type} placement="right">
|
||||
<IconButton
|
||||
onClick={() => {
|
||||
app.selectTool(type);
|
||||
setLastActiveTool(type);
|
||||
}}
|
||||
>
|
||||
<Icon />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
))}
|
||||
</ShapesContainer>
|
||||
}
|
||||
>
|
||||
<Tooltip content="Connector" placement="right" trigger="hover">
|
||||
<IconButton aria-label="Connector">
|
||||
<ConnectorIcon />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</Popover>
|
||||
);
|
||||
};
|
||||
|
||||
const ShapesContainer = styled('div')({
|
||||
width: '64px',
|
||||
display: 'flex',
|
||||
flexWrap: 'wrap',
|
||||
});
|
||||
@@ -0,0 +1,122 @@
|
||||
import { FC, useState, useEffect } from 'react';
|
||||
import {
|
||||
ShapeIcon,
|
||||
RectangleIcon,
|
||||
EllipseIcon,
|
||||
TriangleIcon,
|
||||
PolygonIcon,
|
||||
StarIcon,
|
||||
ArrowIcon,
|
||||
} from '@toeverything/components/icons';
|
||||
import {
|
||||
Tooltip,
|
||||
Popover,
|
||||
IconButton,
|
||||
styled,
|
||||
} from '@toeverything/components/ui';
|
||||
|
||||
import { TDSnapshot, TDShapeType } from '@toeverything/components/board-types';
|
||||
import { TldrawApp } from '@toeverything/components/board-state';
|
||||
|
||||
export type ShapeTypes =
|
||||
| TDShapeType.Rectangle
|
||||
| TDShapeType.Ellipse
|
||||
| TDShapeType.Triangle
|
||||
| TDShapeType.Line
|
||||
| TDShapeType.Hexagon
|
||||
| TDShapeType.Pentagram
|
||||
| TDShapeType.WhiteArrow
|
||||
| TDShapeType.Arrow;
|
||||
|
||||
const shapes = [
|
||||
{
|
||||
type: TDShapeType.Rectangle,
|
||||
label: 'Rectangle',
|
||||
tooltip: 'Rectangle',
|
||||
icon: RectangleIcon,
|
||||
},
|
||||
{
|
||||
type: TDShapeType.WhiteArrow,
|
||||
label: 'WhiteArrow',
|
||||
tooltip: 'WhiteArrow',
|
||||
icon: ArrowIcon,
|
||||
},
|
||||
{
|
||||
type: TDShapeType.Triangle,
|
||||
label: 'Triangle',
|
||||
tooltip: 'Triangle',
|
||||
icon: TriangleIcon,
|
||||
},
|
||||
{
|
||||
type: TDShapeType.Hexagon,
|
||||
label: 'InvertedTranslate',
|
||||
tooltip: 'InvertedTranslate',
|
||||
icon: PolygonIcon,
|
||||
},
|
||||
{
|
||||
type: TDShapeType.Pentagram,
|
||||
label: 'Pentagram',
|
||||
tooltip: 'Pentagram',
|
||||
icon: StarIcon,
|
||||
},
|
||||
{
|
||||
type: TDShapeType.Ellipse,
|
||||
label: 'Ellipse',
|
||||
tooltip: 'Ellipse',
|
||||
icon: EllipseIcon,
|
||||
},
|
||||
] as const;
|
||||
|
||||
const activeToolSelector = (s: TDSnapshot) => s.appState.activeTool;
|
||||
|
||||
export const ShapeTools: FC<{ app: TldrawApp }> = ({ app }) => {
|
||||
const activeTool = app.useStore(activeToolSelector);
|
||||
|
||||
const [lastActiveTool, setLastActiveTool] = useState<ShapeTypes>(
|
||||
TDShapeType.Rectangle
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
shapes.find(s => s.type === activeTool) &&
|
||||
lastActiveTool !== activeTool
|
||||
) {
|
||||
setLastActiveTool(activeTool as ShapeTypes);
|
||||
}
|
||||
}, [activeTool, lastActiveTool]);
|
||||
|
||||
return (
|
||||
<Popover
|
||||
placement="right-start"
|
||||
trigger="click"
|
||||
content={
|
||||
<ShapesContainer>
|
||||
{shapes.map(({ type, label, tooltip, icon: Icon }) => (
|
||||
<Tooltip content={tooltip} key={type} placement="right">
|
||||
<IconButton
|
||||
onClick={() => {
|
||||
app.selectTool(type);
|
||||
setLastActiveTool(type);
|
||||
}}
|
||||
>
|
||||
<Icon />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
))}
|
||||
</ShapesContainer>
|
||||
}
|
||||
>
|
||||
<Tooltip content="Shapes" placement="right" trigger="hover">
|
||||
<IconButton aria-label="Shapes">
|
||||
<ShapeIcon />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</Popover>
|
||||
);
|
||||
};
|
||||
|
||||
const ShapesContainer = styled('div')({
|
||||
width: '64px',
|
||||
display: 'flex',
|
||||
flexWrap: 'wrap',
|
||||
});
|
||||
@@ -0,0 +1,141 @@
|
||||
import { FC } from 'react';
|
||||
import style9 from 'style9';
|
||||
import {
|
||||
// MuiIconButton as IconButton,
|
||||
// MuiTooltip as Tooltip,
|
||||
Tooltip,
|
||||
PopoverContainer,
|
||||
IconButton,
|
||||
} from '@toeverything/components/ui';
|
||||
import {
|
||||
FrameIcon,
|
||||
HandToolIcon,
|
||||
SelectIcon,
|
||||
TextIcon,
|
||||
EraserIcon,
|
||||
} from '@toeverything/components/icons';
|
||||
|
||||
import {
|
||||
TDSnapshot,
|
||||
TDShapeType,
|
||||
TDToolType,
|
||||
} from '@toeverything/components/board-types';
|
||||
import { TldrawApp } from '@toeverything/components/board-state';
|
||||
|
||||
import { ShapeTools } from './ShapeTools';
|
||||
import { PenTools } from './pen-tools';
|
||||
import { LineTools } from './LineTools';
|
||||
|
||||
const activeToolSelector = (s: TDSnapshot) => s.appState.activeTool;
|
||||
const toolLockedSelector = (s: TDSnapshot) => s.appState.isToolLocked;
|
||||
|
||||
const tools: Array<{
|
||||
type: string;
|
||||
label?: string;
|
||||
tooltip?: string;
|
||||
icon?: FC;
|
||||
component?: FC<{ app: TldrawApp }>;
|
||||
}> = [
|
||||
{
|
||||
type: 'select',
|
||||
label: 'Select',
|
||||
tooltip: 'Select',
|
||||
icon: SelectIcon,
|
||||
},
|
||||
{ type: 'frame', label: 'Frame', tooltip: 'Frame', icon: FrameIcon },
|
||||
{
|
||||
type: TDShapeType.Editor,
|
||||
label: 'Text',
|
||||
tooltip: 'Text',
|
||||
icon: TextIcon,
|
||||
},
|
||||
{ type: 'shapes', component: ShapeTools },
|
||||
{ type: 'draw', component: PenTools },
|
||||
{ type: 'Connector', component: LineTools },
|
||||
// { type: 'erase', label: 'Erase', tooltip: 'Erase', icon: EraseIcon },
|
||||
{
|
||||
type: TDShapeType.HandDraw,
|
||||
label: 'HandDraw',
|
||||
tooltip: 'HandDraw',
|
||||
icon: HandToolIcon,
|
||||
},
|
||||
{
|
||||
type: 'erase',
|
||||
label: 'Eraser',
|
||||
tooltip: 'Eraser',
|
||||
icon: EraserIcon,
|
||||
},
|
||||
];
|
||||
|
||||
export const ToolsPanel: FC<{ app: TldrawApp }> = ({ app }) => {
|
||||
const activeTool = app.useStore(activeToolSelector);
|
||||
|
||||
const isToolLocked = app.useStore(toolLockedSelector);
|
||||
|
||||
return (
|
||||
<PopoverContainer
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: '10px',
|
||||
top: '40%',
|
||||
transform: 'translateY(-50%)',
|
||||
}}
|
||||
direction="none"
|
||||
>
|
||||
<div className={styles('container')}>
|
||||
<div className={styles('toolBar')}>
|
||||
{tools.map(
|
||||
({
|
||||
type,
|
||||
label,
|
||||
tooltip,
|
||||
icon: Icon,
|
||||
component: Component,
|
||||
}) =>
|
||||
Component ? (
|
||||
<Component key={type} app={app} />
|
||||
) : (
|
||||
<Tooltip
|
||||
content={tooltip}
|
||||
key={type}
|
||||
placement="right"
|
||||
>
|
||||
<IconButton
|
||||
aria-label={label}
|
||||
style={{
|
||||
color:
|
||||
activeTool === type
|
||||
? 'blue'
|
||||
: '',
|
||||
}}
|
||||
onClick={() => {
|
||||
app.selectTool(type as TDToolType);
|
||||
}}
|
||||
disabled={isToolLocked}
|
||||
>
|
||||
<Icon />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</PopoverContainer>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = style9.create({
|
||||
container: {
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
justifyContent: 'center',
|
||||
height: '100%',
|
||||
},
|
||||
toolBar: {
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
backgroundColor: '#ffffff',
|
||||
borderRadius: '10px',
|
||||
padding: '4px 4px',
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1 @@
|
||||
export { ToolsPanel } from './ToolsPanel';
|
||||
@@ -0,0 +1,44 @@
|
||||
import type { FC, ReactNode } from 'react';
|
||||
import { Tooltip, styled, IconButton } from '@toeverything/components/ui';
|
||||
|
||||
interface PenProps {
|
||||
name: string;
|
||||
icon: ReactNode;
|
||||
primaryColor: string;
|
||||
secondaryColor: string;
|
||||
onClick: () => void;
|
||||
}
|
||||
|
||||
export const Pen: FC<PenProps> = ({
|
||||
name,
|
||||
icon,
|
||||
primaryColor,
|
||||
secondaryColor,
|
||||
onClick,
|
||||
}) => {
|
||||
return (
|
||||
<Tooltip content={name}>
|
||||
<StyledIconButton
|
||||
primaryColor={primaryColor}
|
||||
secondaryColor={secondaryColor}
|
||||
onClick={onClick}
|
||||
>
|
||||
{icon}
|
||||
</StyledIconButton>
|
||||
</Tooltip>
|
||||
);
|
||||
};
|
||||
|
||||
const StyledIconButton = styled(IconButton, {
|
||||
shouldForwardProp: propName =>
|
||||
!['primaryColor', 'secondaryColor'].some(name => name === propName),
|
||||
})<Pick<PenProps, 'primaryColor' | 'secondaryColor'>>(
|
||||
({ primaryColor, secondaryColor }) => {
|
||||
return {
|
||||
'--color-0': primaryColor,
|
||||
'--color-1': secondaryColor,
|
||||
width: '40px',
|
||||
height: '40px',
|
||||
};
|
||||
}
|
||||
);
|
||||
@@ -0,0 +1,200 @@
|
||||
import { FC, ReactElement, type CSSProperties } from 'react';
|
||||
import style9 from 'style9';
|
||||
import {
|
||||
MuiDivider as Divider,
|
||||
Popover,
|
||||
Tooltip,
|
||||
IconButton,
|
||||
styled,
|
||||
} from '@toeverything/components/ui';
|
||||
import { TDShapeType, TDToolType } from '@toeverything/components/board-types';
|
||||
import { TldrawApp } from '@toeverything/components/board-state';
|
||||
import {
|
||||
PencilDuotoneIcon,
|
||||
HighlighterDuotoneIcon,
|
||||
LaserPenDuotoneIcon,
|
||||
} from '@toeverything/components/icons';
|
||||
import { Palette } from '../../palette';
|
||||
import { Pen } from './Pen';
|
||||
|
||||
type PenType = TDShapeType.Pencil | TDShapeType.Highlight | TDShapeType.Laser;
|
||||
|
||||
interface PencilConfig {
|
||||
key: PenType;
|
||||
title: string;
|
||||
icon: ReactElement<any, any>;
|
||||
colors: string[];
|
||||
getColorVars: (
|
||||
primaryColor: string,
|
||||
secondaryColor: string
|
||||
) => { '--color-0': string; '--color-1': string };
|
||||
}
|
||||
|
||||
const PENCIL_CONFIGS: PencilConfig[] = [
|
||||
{
|
||||
key: TDShapeType.Pencil,
|
||||
title: 'Pencil',
|
||||
icon: <PencilDuotoneIcon />,
|
||||
colors: [
|
||||
'#F1675E',
|
||||
'#FF7F22',
|
||||
'#FFCB45',
|
||||
'#40DF9B',
|
||||
'#13D9E3',
|
||||
'#3E6FDB',
|
||||
'#7352F1',
|
||||
'#3A4C5C',
|
||||
'#FFFFFF',
|
||||
],
|
||||
getColorVars: (primaryColor: string, secondaryColor: string) => {
|
||||
return {
|
||||
'--color-0': secondaryColor,
|
||||
'--color-1': primaryColor,
|
||||
};
|
||||
},
|
||||
},
|
||||
{
|
||||
key: TDShapeType.Highlight,
|
||||
title: 'Highlighter',
|
||||
icon: <HighlighterDuotoneIcon />,
|
||||
colors: [
|
||||
'rgba(255, 133, 137, 0.5)',
|
||||
'rgba(255, 159, 101, 0.5)',
|
||||
'rgba(255, 251, 69, 0.5)',
|
||||
'rgba(64, 255, 138, 0.5)',
|
||||
'rgba(26, 252, 255, 0.5)',
|
||||
'rgba(198, 156, 255, 0.5)',
|
||||
'rgba(255, 143, 224, 0.5)',
|
||||
'rgba(152, 172, 189, 0.5)',
|
||||
'rgba(216, 226, 248, 0.5)',
|
||||
],
|
||||
getColorVars: (primaryColor: string, secondaryColor: string) => {
|
||||
return {
|
||||
'--color-0': secondaryColor,
|
||||
'--color-1': primaryColor,
|
||||
};
|
||||
},
|
||||
},
|
||||
{
|
||||
key: TDShapeType.Laser,
|
||||
title: 'Laser',
|
||||
icon: <LaserPenDuotoneIcon />,
|
||||
colors: [
|
||||
'#DB3E3E',
|
||||
'#FF5F1A',
|
||||
'#FFA800',
|
||||
'#13D9E3',
|
||||
'#00ADCE',
|
||||
'#3E6FDB',
|
||||
'#2F5DC2',
|
||||
'#153C7A',
|
||||
],
|
||||
getColorVars: (primaryColor: string, secondaryColor: string) => {
|
||||
return {
|
||||
'--color-0': primaryColor,
|
||||
'--color-1': secondaryColor,
|
||||
};
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const PENCIL_CONFIGS_MAP = PENCIL_CONFIGS.reduce<
|
||||
Record<TDToolType, PencilConfig>
|
||||
>((acc, cur) => {
|
||||
acc[cur.key] = cur;
|
||||
return acc;
|
||||
}, {} as Record<TDToolType, PencilConfig>);
|
||||
|
||||
export const PenTools: FC<{ app: TldrawApp }> = ({ app }) => {
|
||||
const appCurrentTool = app.useStore(state => state.appState.activeTool);
|
||||
const chosenPen =
|
||||
PENCIL_CONFIGS.find(config => config.key === appCurrentTool) ||
|
||||
PENCIL_CONFIGS[0];
|
||||
const chosenPenKey = chosenPen.key;
|
||||
const currentColor = app.useStore(
|
||||
state => state.appState.currentStyle.stroke
|
||||
);
|
||||
const chosenColor = chosenPen.colors.includes(currentColor)
|
||||
? currentColor
|
||||
: chosenPen.colors[0];
|
||||
const isActiveTool = appCurrentTool === chosenPenKey;
|
||||
|
||||
const setPen = (pen: PenType) => {
|
||||
app.selectTool(pen);
|
||||
const penConfig = PENCIL_CONFIGS_MAP[pen];
|
||||
if (!penConfig.colors.includes(currentColor)) {
|
||||
setPenColor(penConfig.colors[0]);
|
||||
}
|
||||
};
|
||||
|
||||
const setPenColor = (color: string) => {
|
||||
app.style({
|
||||
stroke: color,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Popover
|
||||
placement="right-start"
|
||||
trigger="click"
|
||||
content={
|
||||
<Container>
|
||||
<PensContainer>
|
||||
{PENCIL_CONFIGS.map(
|
||||
({ title, icon, key, getColorVars }) => {
|
||||
const active = chosenPenKey === key;
|
||||
const color_vars = getColorVars(
|
||||
active ? '#3A4C5C' : '#98ACBD',
|
||||
active ? chosenColor : '#D8E2F8'
|
||||
);
|
||||
return (
|
||||
<Pen
|
||||
key={key}
|
||||
icon={icon}
|
||||
name={title}
|
||||
primaryColor={color_vars['--color-0']}
|
||||
secondaryColor={color_vars['--color-1']}
|
||||
onClick={() => setPen(key)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
)}
|
||||
</PensContainer>
|
||||
<Divider sx={{ marginBottom: '8px' }} />
|
||||
<Palette
|
||||
selected={chosenColor}
|
||||
colors={PENCIL_CONFIGS_MAP[chosenPenKey].colors}
|
||||
onSelect={color => setPenColor(color)}
|
||||
/>
|
||||
<div />
|
||||
</Container>
|
||||
}
|
||||
>
|
||||
<Tooltip content="Pencil" placement="right">
|
||||
<IconButton
|
||||
aria-label="Pencil"
|
||||
onClick={() => {
|
||||
setPen(chosenPen.key);
|
||||
}}
|
||||
style={{
|
||||
...(chosenPen.getColorVars(
|
||||
isActiveTool ? '#3A4C5C' : '#98ACBD',
|
||||
isActiveTool ? chosenColor : '#D8E2F8'
|
||||
) as CSSProperties),
|
||||
}}
|
||||
>
|
||||
{chosenPen.icon}
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</Popover>
|
||||
);
|
||||
};
|
||||
|
||||
const Container = styled('div')({
|
||||
padding: 0,
|
||||
});
|
||||
|
||||
const PensContainer = styled('div')({
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
});
|
||||
@@ -0,0 +1 @@
|
||||
export { PenTools } from './PenTools';
|
||||
@@ -0,0 +1,62 @@
|
||||
import { FC } from 'react';
|
||||
import {
|
||||
MuiIconButton as IconButton,
|
||||
MuiButton as Button,
|
||||
styled,
|
||||
} from '@toeverything/components/ui';
|
||||
import RemoveIcon from '@mui/icons-material/Remove';
|
||||
import AddIcon from '@mui/icons-material/Add';
|
||||
import UnfoldMoreIcon from '@mui/icons-material/UnfoldMore';
|
||||
|
||||
import { useTldrawApp } from '../../hooks';
|
||||
import { TDSnapshot } from '@toeverything/components/board-types';
|
||||
|
||||
import { MiniMap } from './mini-map';
|
||||
|
||||
const zoomSelector = (s: TDSnapshot) =>
|
||||
s.document.pageStates[s.appState.currentPageId].camera.zoom;
|
||||
|
||||
export const ZoomBar: FC = () => {
|
||||
const app = useTldrawApp();
|
||||
const zoom = app.useStore(zoomSelector);
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{ position: 'absolute', right: 10, bottom: 10, zIndex: 200 }}
|
||||
>
|
||||
<MiniMapContainer>
|
||||
<MiniMap />
|
||||
</MiniMapContainer>
|
||||
|
||||
<div
|
||||
style={{
|
||||
padding: '0 10px',
|
||||
background: '#ffffff',
|
||||
borderRadius: '8px',
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
variant="text"
|
||||
style={{ color: 'rgba(0,0,0,0.5)' }}
|
||||
onClick={app.resetZoom}
|
||||
>
|
||||
{Math.round(zoom * 100)}%
|
||||
</Button>
|
||||
<IconButton onClick={app.zoomOut}>
|
||||
<RemoveIcon />
|
||||
</IconButton>
|
||||
<IconButton onClick={app.zoomIn}>
|
||||
<AddIcon />
|
||||
</IconButton>
|
||||
<IconButton onClick={app.zoomToFit}>
|
||||
<UnfoldMoreIcon style={{ transform: 'rotateZ(90deg)' }} />
|
||||
</IconButton>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const MiniMapContainer = styled('div')({
|
||||
display: 'flex',
|
||||
justifyContent: 'flex-end',
|
||||
});
|
||||
@@ -0,0 +1 @@
|
||||
export { ZoomBar } from './ZoomBar';
|
||||
@@ -0,0 +1,88 @@
|
||||
import type { FC } from 'react';
|
||||
import { Utils } from '@tldraw/core';
|
||||
import Vec from '@tldraw/vec';
|
||||
import { TLDR } from '@toeverything/components/board-state';
|
||||
import { styled } from '@toeverything/components/ui';
|
||||
import { useTldrawApp } from '../../../hooks';
|
||||
|
||||
import { SimplifiedShape } from './SimplifiedShape';
|
||||
import { Viewport } from './Viewport';
|
||||
import { processBound, getViewportBound } from './bounds';
|
||||
|
||||
const MINI_MAP_WIDTH = 150;
|
||||
const MINI_MAP_HEIGHT = 100;
|
||||
|
||||
const getScaleToMap = (width: number, height: number) => {
|
||||
const scaleWidth = width / MINI_MAP_WIDTH;
|
||||
const scaleHeight = height / MINI_MAP_HEIGHT;
|
||||
return scaleWidth > scaleHeight ? scaleWidth : scaleHeight;
|
||||
};
|
||||
|
||||
export const MiniMap: FC = () => {
|
||||
const app = useTldrawApp();
|
||||
const page = app.useStore(s => s.document.pages[s.appState.currentPageId]);
|
||||
const pageState = app.useStore(
|
||||
s => s.document.pageStates[s.appState.currentPageId]
|
||||
);
|
||||
const viewportBound = getViewportBound(
|
||||
app.rendererBounds,
|
||||
pageState.camera
|
||||
);
|
||||
|
||||
const shapes = Object.values(page.shapes);
|
||||
const bounds = shapes.map(shape => TLDR.get_bounds(shape));
|
||||
const commonBound = Utils.getCommonBounds(bounds.concat(viewportBound));
|
||||
const scaleToMap = commonBound
|
||||
? getScaleToMap(commonBound.width, commonBound.height)
|
||||
: 1;
|
||||
const processedViewportBound = processBound({
|
||||
bound: viewportBound,
|
||||
scale: scaleToMap,
|
||||
commonBound,
|
||||
});
|
||||
|
||||
return (
|
||||
<Container>
|
||||
<ShapesContainer>
|
||||
{shapes.map((shape, index) => {
|
||||
const bound = processBound({
|
||||
bound: bounds[index],
|
||||
scale: scaleToMap,
|
||||
commonBound,
|
||||
});
|
||||
return (
|
||||
<SimplifiedShape
|
||||
key={shape.id}
|
||||
{...bound}
|
||||
onClick={() => {
|
||||
app.zoomToShapes([shape]);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
<Viewport
|
||||
{...processedViewportBound}
|
||||
onPan={delta => {
|
||||
app.pan(Vec.mul(delta, scaleToMap));
|
||||
}}
|
||||
/>
|
||||
</ShapesContainer>
|
||||
</Container>
|
||||
);
|
||||
};
|
||||
|
||||
const Container = styled('div')(({ theme }) => ({
|
||||
display: 'inline-block',
|
||||
padding: '10px',
|
||||
borderColor: theme.affine.palette.borderColor,
|
||||
borderWidth: '1px',
|
||||
borderStyle: 'solid',
|
||||
borderRadius: '4px',
|
||||
backgroundColor: theme.affine.palette.white,
|
||||
}));
|
||||
|
||||
const ShapesContainer = styled('div')({
|
||||
position: 'relative',
|
||||
width: `${MINI_MAP_WIDTH}px`,
|
||||
height: `${MINI_MAP_HEIGHT}px`,
|
||||
});
|
||||
@@ -0,0 +1,35 @@
|
||||
import type { FC, CSSProperties } from 'react';
|
||||
import type { TLBounds } from '@tldraw/core';
|
||||
import { styled } from '@toeverything/components/ui';
|
||||
|
||||
interface SimplifiedShapeProps extends TLBounds {
|
||||
onClick?: () => void;
|
||||
}
|
||||
|
||||
export const SimplifiedShape: FC<SimplifiedShapeProps> = ({
|
||||
onClick,
|
||||
width,
|
||||
height,
|
||||
minX,
|
||||
minY,
|
||||
}) => {
|
||||
const style: CSSProperties = {
|
||||
width: `${width}px`,
|
||||
height: `${height}px`,
|
||||
left: `${minX}px`,
|
||||
top: `${minY}px`,
|
||||
};
|
||||
return <Container style={style} onClick={onClick} />;
|
||||
};
|
||||
|
||||
const Container = styled('div')(({ theme }) => ({
|
||||
position: 'absolute',
|
||||
backgroundColor: theme.affine.palette.icons,
|
||||
opacity: 0.2,
|
||||
cursor: 'pointer',
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
'&:hover': {
|
||||
opacity: 0.8,
|
||||
},
|
||||
}));
|
||||
@@ -0,0 +1,69 @@
|
||||
import type { FC, CSSProperties, PointerEventHandler } from 'react';
|
||||
import { useState, useRef } from 'react';
|
||||
import type { TLBounds } from '@tldraw/core';
|
||||
import Vec from '@tldraw/vec';
|
||||
import { styled, alpha } from '@toeverything/components/ui';
|
||||
|
||||
interface ViewportProps extends TLBounds {
|
||||
onPan?: (delta: [number, number]) => void;
|
||||
}
|
||||
|
||||
export const Viewport: FC<ViewportProps> = ({
|
||||
onPan,
|
||||
width,
|
||||
height,
|
||||
minX,
|
||||
minY,
|
||||
}) => {
|
||||
const style: CSSProperties = {
|
||||
width: `${width}px`,
|
||||
height: `${height}px`,
|
||||
left: `${minX}px`,
|
||||
top: `${minY}px`,
|
||||
};
|
||||
const [dragging, setDragging] = useState(false);
|
||||
const lastPosition = useRef<[number, number]>([0, 0]);
|
||||
const onPointerDown: PointerEventHandler<HTMLDivElement> = e => {
|
||||
setDragging(true);
|
||||
lastPosition.current = [e.clientX, e.clientY];
|
||||
const onPointerMove = (ev: PointerEvent) => {
|
||||
const newPosition = [ev.clientX, ev.clientY];
|
||||
const delta = Vec.sub(newPosition, lastPosition.current);
|
||||
lastPosition.current = newPosition as [number, number];
|
||||
onPan?.(delta as [number, number]);
|
||||
};
|
||||
const onPointerUp = () => {
|
||||
lastPosition.current = [0, 0];
|
||||
setDragging(false);
|
||||
document.removeEventListener('pointermove', onPointerMove);
|
||||
document.removeEventListener('pointerup', onPointerUp);
|
||||
};
|
||||
document.addEventListener('pointermove', onPointerMove);
|
||||
document.addEventListener('pointerup', onPointerUp);
|
||||
};
|
||||
return (
|
||||
<Container
|
||||
style={style}
|
||||
onPointerDown={onPointerDown}
|
||||
dragging={dragging}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const Container = styled('div')<{ dragging?: boolean }>(
|
||||
({ theme, dragging }) => ({
|
||||
position: 'absolute',
|
||||
borderColor: theme.affine.palette.primary,
|
||||
borderWidth: '1px',
|
||||
borderStyle: 'solid',
|
||||
cursor: 'pointer',
|
||||
backgroundColor: dragging
|
||||
? alpha(theme.affine.palette.icons, 0.2)
|
||||
: 'unset',
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
'&:hover': {
|
||||
backgroundColor: alpha(theme.affine.palette.icons, 0.2),
|
||||
},
|
||||
})
|
||||
);
|
||||
@@ -0,0 +1,60 @@
|
||||
import type { TLBounds, TLPageState } from '@tldraw/core';
|
||||
|
||||
const getOffsetBound = (bound: TLBounds, commonBound: TLBounds): TLBounds => {
|
||||
return {
|
||||
width: bound.width,
|
||||
height: bound.height,
|
||||
minX: bound.minX - commonBound.minX,
|
||||
maxX: bound.maxX - commonBound.maxX,
|
||||
minY: bound.minY - commonBound.minY,
|
||||
maxY: bound.maxY - commonBound.maxY,
|
||||
};
|
||||
};
|
||||
|
||||
const getScaledBound = (bound: TLBounds, scale: number): TLBounds => {
|
||||
return {
|
||||
width: bound.width / scale,
|
||||
height: bound.height / scale,
|
||||
minX: bound.minX / scale,
|
||||
maxX: bound.maxX / scale,
|
||||
minY: bound.minY / scale,
|
||||
maxY: bound.maxY / scale,
|
||||
};
|
||||
};
|
||||
|
||||
interface ProcessBoundProps {
|
||||
bound: TLBounds;
|
||||
scale: number;
|
||||
commonBound: TLBounds;
|
||||
}
|
||||
|
||||
export const processBound = ({
|
||||
bound,
|
||||
scale,
|
||||
commonBound,
|
||||
}: ProcessBoundProps) => {
|
||||
let boundResult = bound;
|
||||
boundResult = getOffsetBound(boundResult, commonBound);
|
||||
boundResult = getScaledBound(boundResult, scale);
|
||||
return boundResult;
|
||||
};
|
||||
|
||||
export const getViewportBound = (
|
||||
rendererBounds: TLBounds,
|
||||
camera: TLPageState['camera']
|
||||
): TLBounds => {
|
||||
const [cameraX, cameraY] = camera.point;
|
||||
const zoom = camera.zoom;
|
||||
const minX = 0 - cameraX;
|
||||
const minY = 0 - cameraY;
|
||||
const width = rendererBounds.width / zoom;
|
||||
const height = rendererBounds.height / zoom;
|
||||
return {
|
||||
width,
|
||||
height,
|
||||
minX,
|
||||
maxX: minX + width,
|
||||
minY,
|
||||
maxY: minY + height,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
export { MiniMap } from './MiniMap';
|
||||
97
libs/components/board-draw/src/constants.ts
Normal file
97
libs/components/board-draw/src/constants.ts
Normal file
@@ -0,0 +1,97 @@
|
||||
import type { Easing } from '@toeverything/components/board-types';
|
||||
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
export const LETTER_SPACING = '-0.03em';
|
||||
export const LINE_HEIGHT = 1;
|
||||
export const GRID_SIZE = 8;
|
||||
export const SVG_EXPORT_PADDING = 16;
|
||||
export const BINDING_DISTANCE = 16;
|
||||
export const CLONING_DISTANCE = 32;
|
||||
export const FIT_TO_SCREEN_PADDING = 128;
|
||||
export const SNAP_DISTANCE = 5;
|
||||
export const EMPTY_ARRAY = [] as any[];
|
||||
export const SLOW_SPEED = 10;
|
||||
export const VERY_SLOW_SPEED = 2.5;
|
||||
export const GHOSTED_OPACITY = 0.3;
|
||||
export const DEAD_ZONE = 3;
|
||||
export const LABEL_POINT = [0.5, 0.5];
|
||||
|
||||
export const PI2 = Math.PI * 2;
|
||||
|
||||
export const EASINGS: Record<Easing, (t: number) => number> = {
|
||||
linear: t => t,
|
||||
easeInQuad: t => t * t,
|
||||
easeOutQuad: t => t * (2 - t),
|
||||
easeInOutQuad: t => (t < 0.5 ? 2 * t * t : -1 + (4 - 2 * t) * t),
|
||||
easeInCubic: t => t * t * t,
|
||||
easeOutCubic: t => --t * t * t + 1,
|
||||
easeInOutCubic: t =>
|
||||
t < 0.5 ? 4 * t * t * t : (t - 1) * (2 * t - 2) * (2 * t - 2) + 1,
|
||||
easeInQuart: t => t * t * t * t,
|
||||
easeOutQuart: t => 1 - --t * t * t * t,
|
||||
easeInOutQuart: t =>
|
||||
t < 0.5 ? 8 * t * t * t * t : 1 - 8 * --t * t * t * t,
|
||||
easeInQuint: t => t * t * t * t * t,
|
||||
easeOutQuint: t => 1 + --t * t * t * t * t,
|
||||
easeInOutQuint: t =>
|
||||
t < 0.5 ? 16 * t * t * t * t * t : 1 + 16 * --t * t * t * t * t,
|
||||
easeInSine: t => 1 - Math.cos((t * Math.PI) / 2),
|
||||
easeOutSine: t => Math.sin((t * Math.PI) / 2),
|
||||
easeInOutSine: t => -(Math.cos(Math.PI * t) - 1) / 2,
|
||||
easeInExpo: t => (t <= 0 ? 0 : Math.pow(2, 10 * t - 10)),
|
||||
easeOutExpo: t => (t >= 1 ? 1 : 1 - Math.pow(2, -10 * t)),
|
||||
easeInOutExpo: t =>
|
||||
t <= 0
|
||||
? 0
|
||||
: t >= 1
|
||||
? 1
|
||||
: t < 0.5
|
||||
? Math.pow(2, 20 * t - 10) / 2
|
||||
: (2 - Math.pow(2, -20 * t + 10)) / 2,
|
||||
};
|
||||
|
||||
export const EASING_STRINGS: Record<Easing, string> = {
|
||||
linear: `(t) => t`,
|
||||
easeInQuad: `(t) => t * t`,
|
||||
easeOutQuad: `(t) => t * (2 - t)`,
|
||||
easeInOutQuad: `(t) => (t < 0.5 ? 2 * t * t : -1 + (4 - 2 * t) * t)`,
|
||||
easeInCubic: `(t) => t * t * t`,
|
||||
easeOutCubic: `(t) => --t * t * t + 1`,
|
||||
easeInOutCubic: `(t) => t < 0.5 ? 4 * t * t * t : (t - 1) * (2 * t - 2) * (2 * t - 2) + 1`,
|
||||
easeInQuart: `(t) => t * t * t * t`,
|
||||
easeOutQuart: `(t) => 1 - --t * t * t * t`,
|
||||
easeInOutQuart: `(t) => t < 0.5 ? 8 * t * t * t * t : 1 - 8 * --t * t * t * t`,
|
||||
easeInQuint: `(t) => t * t * t * t * t`,
|
||||
easeOutQuint: `(t) => 1 + --t * t * t * t * t`,
|
||||
easeInOutQuint: `(t) => t < 0.5 ? 16 * t * t * t * t * t : 1 + 16 * --t * t * t * t * t`,
|
||||
easeInSine: `(t) => 1 - Math.cos((t * Math.PI) / 2)`,
|
||||
easeOutSine: `(t) => Math.sin((t * Math.PI) / 2)`,
|
||||
easeInOutSine: `(t) => -(Math.cos(Math.PI * t) - 1) / 2`,
|
||||
easeInExpo: `(t) => (t <= 0 ? 0 : Math.pow(2, 10 * t - 10))`,
|
||||
easeOutExpo: `(t) => (t >= 1 ? 1 : 1 - Math.pow(2, -10 * t))`,
|
||||
easeInOutExpo: `(t) => t <= 0 ? 0 : t >= 1 ? 1 : t < 0.5 ? Math.pow(2, 20 * t - 10) / 2 : (2 - Math.pow(2, -20 * t + 10)) / 2`,
|
||||
};
|
||||
|
||||
export const USER_COLORS = [
|
||||
'#EC5E41',
|
||||
'#F2555A',
|
||||
'#F04F88',
|
||||
'#E34BA9',
|
||||
'#BD54C6',
|
||||
'#9D5BD2',
|
||||
'#7B66DC',
|
||||
'#02B1CC',
|
||||
'#11B3A3',
|
||||
'#39B178',
|
||||
'#55B467',
|
||||
'#FF802B',
|
||||
];
|
||||
|
||||
export const isSafari =
|
||||
typeof Window === 'undefined'
|
||||
? false
|
||||
: /^((?!chrome|android).)*safari/i.test(navigator.userAgent);
|
||||
|
||||
export const IMAGE_EXTENSIONS = ['.png', '.svg', '.jpg', '.jpeg', '.gif'];
|
||||
|
||||
export const VIDEO_EXTENSIONS = isSafari ? [] : ['.mp4', '.webm'];
|
||||
6
libs/components/board-draw/src/hooks/index.ts
Normal file
6
libs/components/board-draw/src/hooks/index.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export * from './use-keyboard-shortcuts';
|
||||
export * from './use-tldraw-app';
|
||||
// export * from './useTheme';
|
||||
export * from './use-stylesheet';
|
||||
export * from './use-file-system-handlers';
|
||||
// export * from './useFileSystem';
|
||||
@@ -0,0 +1,54 @@
|
||||
import * as React from 'react';
|
||||
import { useTldrawApp } from './use-tldraw-app';
|
||||
|
||||
export function useFileSystemHandlers() {
|
||||
const app = useTldrawApp();
|
||||
|
||||
const onNewProject = React.useCallback(
|
||||
async (e?: React.MouseEvent | React.KeyboardEvent | KeyboardEvent) => {
|
||||
if (e && app.callbacks.onOpenProject) e.preventDefault();
|
||||
app.callbacks.onNewProject?.(app);
|
||||
},
|
||||
[app]
|
||||
);
|
||||
|
||||
const onSaveProject = React.useCallback(
|
||||
(e?: React.MouseEvent | React.KeyboardEvent | KeyboardEvent) => {
|
||||
if (e && app.callbacks.onOpenProject) e.preventDefault();
|
||||
app.callbacks.onSaveProject?.(app);
|
||||
},
|
||||
[app]
|
||||
);
|
||||
|
||||
const onSaveProjectAs = React.useCallback(
|
||||
(e?: React.MouseEvent | React.KeyboardEvent | KeyboardEvent) => {
|
||||
if (e && app.callbacks.onOpenProject) e.preventDefault();
|
||||
app.callbacks.onSaveProjectAs?.(app);
|
||||
},
|
||||
[app]
|
||||
);
|
||||
|
||||
const onOpenProject = React.useCallback(
|
||||
async (e?: React.MouseEvent | React.KeyboardEvent | KeyboardEvent) => {
|
||||
if (e && app.callbacks.onOpenProject) e.preventDefault();
|
||||
app.callbacks.onOpenProject?.(app);
|
||||
},
|
||||
[app]
|
||||
);
|
||||
|
||||
const onOpenMedia = React.useCallback(
|
||||
async (e?: React.MouseEvent | React.KeyboardEvent | KeyboardEvent) => {
|
||||
if (e && app.callbacks.onOpenMedia) e.preventDefault();
|
||||
app.callbacks.onOpenMedia?.(app);
|
||||
},
|
||||
[app]
|
||||
);
|
||||
|
||||
return {
|
||||
onNewProject,
|
||||
onSaveProject,
|
||||
onSaveProjectAs,
|
||||
onOpenProject,
|
||||
onOpenMedia,
|
||||
};
|
||||
}
|
||||
679
libs/components/board-draw/src/hooks/use-keyboard-shortcuts.tsx
Normal file
679
libs/components/board-draw/src/hooks/use-keyboard-shortcuts.tsx
Normal file
@@ -0,0 +1,679 @@
|
||||
import * as React from 'react';
|
||||
import { useHotkeys } from 'react-hotkeys-hook';
|
||||
import { AlignStyle, TDShapeType } from '@toeverything/components/board-types';
|
||||
import { useTldrawApp } from './use-tldraw-app';
|
||||
import { useFileSystemHandlers } from './use-file-system-handlers';
|
||||
|
||||
export function useKeyboardShortcuts(ref: React.RefObject<HTMLDivElement>) {
|
||||
const app = useTldrawApp();
|
||||
|
||||
const canHandleEvent = React.useCallback(
|
||||
(ignoreMenus = false) => {
|
||||
const elm = ref.current;
|
||||
if (
|
||||
ignoreMenus &&
|
||||
(app.isMenuOpen || app.settings.keepStyleMenuOpen)
|
||||
)
|
||||
return true;
|
||||
return (
|
||||
elm &&
|
||||
(document.activeElement === elm ||
|
||||
elm.contains(document.activeElement))
|
||||
);
|
||||
},
|
||||
[ref]
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!app) return;
|
||||
|
||||
const handleCut = (e: ClipboardEvent) => {
|
||||
if (!canHandleEvent(true)) return;
|
||||
|
||||
if (app.readOnly) {
|
||||
app.copy(undefined, undefined, e);
|
||||
return;
|
||||
}
|
||||
|
||||
app.cut(undefined, undefined, e);
|
||||
};
|
||||
|
||||
const handleCopy = (e: ClipboardEvent) => {
|
||||
if (!canHandleEvent(true)) return;
|
||||
|
||||
app.copy(undefined, undefined, e);
|
||||
};
|
||||
|
||||
const handlePaste = (e: ClipboardEvent) => {
|
||||
if (!canHandleEvent(true)) return;
|
||||
if (app.readOnly) return;
|
||||
|
||||
app.paste(undefined, e);
|
||||
};
|
||||
|
||||
document.addEventListener('cut', handleCut);
|
||||
document.addEventListener('copy', handleCopy);
|
||||
document.addEventListener('paste', handlePaste);
|
||||
return () => {
|
||||
document.removeEventListener('cut', handleCut);
|
||||
document.removeEventListener('copy', handleCopy);
|
||||
document.removeEventListener('paste', handlePaste);
|
||||
};
|
||||
}, [app]);
|
||||
|
||||
/* ---------------------- Tools --------------------- */
|
||||
|
||||
useHotkeys(
|
||||
'v,1',
|
||||
() => {
|
||||
if (!canHandleEvent(true)) return;
|
||||
app.selectTool('select');
|
||||
},
|
||||
[app, ref.current]
|
||||
);
|
||||
|
||||
useHotkeys(
|
||||
'd,p,2',
|
||||
() => {
|
||||
if (!canHandleEvent(true)) return;
|
||||
app.selectTool(TDShapeType.Draw);
|
||||
},
|
||||
undefined,
|
||||
[app]
|
||||
);
|
||||
|
||||
useHotkeys(
|
||||
'e,3',
|
||||
() => {
|
||||
if (!canHandleEvent(true)) return;
|
||||
app.selectTool('erase');
|
||||
},
|
||||
undefined,
|
||||
[app]
|
||||
);
|
||||
|
||||
useHotkeys(
|
||||
'r,4',
|
||||
() => {
|
||||
if (!canHandleEvent(true)) return;
|
||||
app.selectTool(TDShapeType.Rectangle);
|
||||
},
|
||||
undefined,
|
||||
[app]
|
||||
);
|
||||
|
||||
useHotkeys(
|
||||
'o,5',
|
||||
() => {
|
||||
if (!canHandleEvent(true)) return;
|
||||
app.selectTool(TDShapeType.Ellipse);
|
||||
},
|
||||
undefined,
|
||||
[app]
|
||||
);
|
||||
|
||||
useHotkeys(
|
||||
'g,6',
|
||||
() => {
|
||||
if (!canHandleEvent()) return;
|
||||
app.selectTool(TDShapeType.Triangle);
|
||||
},
|
||||
undefined,
|
||||
[app]
|
||||
);
|
||||
|
||||
useHotkeys(
|
||||
'l,7',
|
||||
() => {
|
||||
if (!canHandleEvent(true)) return;
|
||||
app.selectTool(TDShapeType.Line);
|
||||
},
|
||||
undefined,
|
||||
[app]
|
||||
);
|
||||
|
||||
useHotkeys(
|
||||
'a,8',
|
||||
() => {
|
||||
if (!canHandleEvent(true)) return;
|
||||
app.selectTool(TDShapeType.Arrow);
|
||||
},
|
||||
undefined,
|
||||
[app]
|
||||
);
|
||||
|
||||
/* ---------------------- Misc ---------------------- */
|
||||
|
||||
// Dark Mode
|
||||
|
||||
useHotkeys(
|
||||
'ctrl+shift+d,⌘+shift+d',
|
||||
e => {
|
||||
if (!canHandleEvent(true)) return;
|
||||
app.toggleDarkMode();
|
||||
e.preventDefault();
|
||||
},
|
||||
undefined,
|
||||
[app]
|
||||
);
|
||||
|
||||
// Focus Mode
|
||||
|
||||
useHotkeys(
|
||||
'ctrl+.,⌘+.',
|
||||
() => {
|
||||
if (!canHandleEvent(true)) return;
|
||||
app.toggleFocusMode();
|
||||
},
|
||||
undefined,
|
||||
[app]
|
||||
);
|
||||
|
||||
useHotkeys(
|
||||
'ctrl+shift+g,⌘+shift+g',
|
||||
() => {
|
||||
if (!canHandleEvent(true)) return;
|
||||
app.toggleGrid();
|
||||
},
|
||||
undefined,
|
||||
[app]
|
||||
);
|
||||
|
||||
// File System
|
||||
|
||||
const {
|
||||
onNewProject,
|
||||
onOpenProject,
|
||||
onSaveProject,
|
||||
onSaveProjectAs,
|
||||
onOpenMedia,
|
||||
} = useFileSystemHandlers();
|
||||
|
||||
useHotkeys(
|
||||
'ctrl+n,⌘+n',
|
||||
e => {
|
||||
if (!canHandleEvent()) return;
|
||||
|
||||
onNewProject(e);
|
||||
},
|
||||
undefined,
|
||||
[app]
|
||||
);
|
||||
useHotkeys(
|
||||
'ctrl+s,⌘+s',
|
||||
e => {
|
||||
if (!canHandleEvent()) return;
|
||||
|
||||
onSaveProject(e);
|
||||
},
|
||||
undefined,
|
||||
[app]
|
||||
);
|
||||
|
||||
useHotkeys(
|
||||
'ctrl+shift+s,⌘+shift+s',
|
||||
e => {
|
||||
if (!canHandleEvent()) return;
|
||||
|
||||
onSaveProjectAs(e);
|
||||
},
|
||||
undefined,
|
||||
[app]
|
||||
);
|
||||
useHotkeys(
|
||||
'ctrl+o,⌘+o',
|
||||
e => {
|
||||
if (!canHandleEvent()) return;
|
||||
|
||||
onOpenProject(e);
|
||||
},
|
||||
undefined,
|
||||
[app]
|
||||
);
|
||||
useHotkeys(
|
||||
'ctrl+u,⌘+u',
|
||||
e => {
|
||||
if (!canHandleEvent()) return;
|
||||
onOpenMedia(e);
|
||||
},
|
||||
undefined,
|
||||
[app]
|
||||
);
|
||||
|
||||
// Undo Redo
|
||||
|
||||
useHotkeys(
|
||||
'⌘+z,ctrl+z',
|
||||
() => {
|
||||
if (!canHandleEvent(true)) return;
|
||||
|
||||
if (app.session) {
|
||||
app.cancelSession();
|
||||
} else {
|
||||
app.undo();
|
||||
}
|
||||
},
|
||||
undefined,
|
||||
[app]
|
||||
);
|
||||
|
||||
useHotkeys(
|
||||
'ctrl+shift+z,⌘+shift+z',
|
||||
() => {
|
||||
if (!canHandleEvent(true)) return;
|
||||
|
||||
if (app.session) {
|
||||
app.cancelSession();
|
||||
} else {
|
||||
app.redo();
|
||||
}
|
||||
},
|
||||
undefined,
|
||||
[app]
|
||||
);
|
||||
|
||||
// Undo Redo
|
||||
|
||||
useHotkeys(
|
||||
'⌘+u,ctrl+u',
|
||||
() => {
|
||||
if (!canHandleEvent()) return;
|
||||
app.undoSelect();
|
||||
},
|
||||
undefined,
|
||||
[app]
|
||||
);
|
||||
|
||||
useHotkeys(
|
||||
'ctrl+shift-u,⌘+shift+u',
|
||||
() => {
|
||||
if (!canHandleEvent()) return;
|
||||
app.redoSelect();
|
||||
},
|
||||
undefined,
|
||||
[app]
|
||||
);
|
||||
|
||||
/* -------------------- Commands -------------------- */
|
||||
|
||||
// Camera
|
||||
|
||||
useHotkeys(
|
||||
'ctrl+=,⌘+=,ctrl+num_subtract,⌘+num_subtract',
|
||||
e => {
|
||||
if (!canHandleEvent(true)) return;
|
||||
app.zoomIn();
|
||||
e.preventDefault();
|
||||
},
|
||||
undefined,
|
||||
[app]
|
||||
);
|
||||
|
||||
useHotkeys(
|
||||
'ctrl+-,⌘+-,ctrl+num_add,⌘+num_add',
|
||||
e => {
|
||||
if (!canHandleEvent(true)) return;
|
||||
|
||||
app.zoomOut();
|
||||
e.preventDefault();
|
||||
},
|
||||
undefined,
|
||||
[app]
|
||||
);
|
||||
|
||||
useHotkeys(
|
||||
'shift+0,ctrl+numpad_0,⌘+numpad_0',
|
||||
() => {
|
||||
if (!canHandleEvent(true)) return;
|
||||
app.resetZoom();
|
||||
},
|
||||
undefined,
|
||||
[app]
|
||||
);
|
||||
|
||||
useHotkeys(
|
||||
'shift+1',
|
||||
() => {
|
||||
if (!canHandleEvent(true)) return;
|
||||
app.zoomToFit();
|
||||
},
|
||||
undefined,
|
||||
[app]
|
||||
);
|
||||
|
||||
useHotkeys(
|
||||
'shift+2',
|
||||
() => {
|
||||
if (!canHandleEvent(true)) return;
|
||||
app.zoomToSelection();
|
||||
},
|
||||
undefined,
|
||||
[app]
|
||||
);
|
||||
|
||||
// Duplicate
|
||||
|
||||
useHotkeys(
|
||||
'ctrl+d,⌘+d',
|
||||
e => {
|
||||
if (!canHandleEvent()) return;
|
||||
|
||||
app.duplicate();
|
||||
e.preventDefault();
|
||||
},
|
||||
undefined,
|
||||
[app]
|
||||
);
|
||||
|
||||
// Flip
|
||||
|
||||
useHotkeys(
|
||||
'shift+h',
|
||||
() => {
|
||||
if (!canHandleEvent(true)) return;
|
||||
app.flipHorizontal();
|
||||
},
|
||||
undefined,
|
||||
[app]
|
||||
);
|
||||
|
||||
useHotkeys(
|
||||
'shift+v',
|
||||
() => {
|
||||
if (!canHandleEvent(true)) return;
|
||||
app.flipVertical();
|
||||
},
|
||||
undefined,
|
||||
[app]
|
||||
);
|
||||
|
||||
// Cancel
|
||||
|
||||
useHotkeys(
|
||||
'escape',
|
||||
() => {
|
||||
if (!canHandleEvent(true)) return;
|
||||
|
||||
app.cancel();
|
||||
},
|
||||
undefined,
|
||||
[app]
|
||||
);
|
||||
|
||||
// Delete
|
||||
|
||||
useHotkeys(
|
||||
'backspace,del',
|
||||
() => {
|
||||
if (!canHandleEvent()) return;
|
||||
app.delete();
|
||||
},
|
||||
undefined,
|
||||
[app]
|
||||
);
|
||||
|
||||
// Select All
|
||||
|
||||
useHotkeys(
|
||||
'⌘+a,ctrl+a',
|
||||
() => {
|
||||
if (!canHandleEvent(true)) return;
|
||||
app.selectAll();
|
||||
},
|
||||
undefined,
|
||||
[app]
|
||||
);
|
||||
|
||||
// Nudge
|
||||
|
||||
useHotkeys(
|
||||
'up',
|
||||
() => {
|
||||
if (!canHandleEvent()) return;
|
||||
app.nudge([0, -1], false);
|
||||
},
|
||||
undefined,
|
||||
[app]
|
||||
);
|
||||
|
||||
useHotkeys(
|
||||
'right',
|
||||
() => {
|
||||
if (!canHandleEvent()) return;
|
||||
app.nudge([1, 0], false);
|
||||
},
|
||||
undefined,
|
||||
[app]
|
||||
);
|
||||
|
||||
useHotkeys(
|
||||
'down',
|
||||
() => {
|
||||
if (!canHandleEvent()) return;
|
||||
app.nudge([0, 1], false);
|
||||
},
|
||||
undefined,
|
||||
[app]
|
||||
);
|
||||
|
||||
useHotkeys(
|
||||
'left',
|
||||
() => {
|
||||
if (!canHandleEvent()) return;
|
||||
app.nudge([-1, 0], false);
|
||||
},
|
||||
undefined,
|
||||
[app]
|
||||
);
|
||||
|
||||
useHotkeys(
|
||||
'shift+up',
|
||||
() => {
|
||||
if (!canHandleEvent()) return;
|
||||
app.nudge([0, -1], true);
|
||||
},
|
||||
undefined,
|
||||
[app]
|
||||
);
|
||||
|
||||
useHotkeys(
|
||||
'shift+right',
|
||||
() => {
|
||||
if (!canHandleEvent()) return;
|
||||
app.nudge([1, 0], true);
|
||||
},
|
||||
undefined,
|
||||
[app]
|
||||
);
|
||||
|
||||
useHotkeys(
|
||||
'shift+down',
|
||||
() => {
|
||||
if (!canHandleEvent()) return;
|
||||
app.nudge([0, 1], true);
|
||||
},
|
||||
undefined,
|
||||
[app]
|
||||
);
|
||||
|
||||
useHotkeys(
|
||||
'shift+left',
|
||||
() => {
|
||||
if (!canHandleEvent()) return;
|
||||
app.nudge([-1, 0], true);
|
||||
},
|
||||
undefined,
|
||||
[app]
|
||||
);
|
||||
|
||||
useHotkeys(
|
||||
'⌘+shift+l,ctrl+shift+l',
|
||||
() => {
|
||||
if (!canHandleEvent()) return;
|
||||
app.toggleLocked();
|
||||
},
|
||||
undefined,
|
||||
[app]
|
||||
);
|
||||
|
||||
// Copy, Cut & Paste
|
||||
|
||||
// useHotkeys(
|
||||
// '⌘+c,ctrl+c',
|
||||
// () => {
|
||||
// if (!canHandleEvent()) return
|
||||
// app.copy()
|
||||
// },
|
||||
// undefined,
|
||||
// [app]
|
||||
// )
|
||||
|
||||
useHotkeys(
|
||||
'⌘+shift+c,ctrl+shift+c',
|
||||
e => {
|
||||
if (!canHandleEvent()) return;
|
||||
|
||||
app.copySvg();
|
||||
e.preventDefault();
|
||||
},
|
||||
undefined,
|
||||
[app]
|
||||
);
|
||||
|
||||
// useHotkeys(
|
||||
// '⌘+x,ctrl+x',
|
||||
// () => {
|
||||
// if (!canHandleEvent()) return
|
||||
// app.cut()
|
||||
// },
|
||||
// undefined,
|
||||
// [app]
|
||||
// )
|
||||
|
||||
// useHotkeys(
|
||||
// '⌘+v,ctrl+v',
|
||||
// () => {
|
||||
// if (!canHandleEvent()) return
|
||||
|
||||
// app.paste()
|
||||
// },
|
||||
// undefined,
|
||||
// [app]
|
||||
// )
|
||||
|
||||
// Group & Ungroup
|
||||
|
||||
useHotkeys(
|
||||
'⌘+g,ctrl+g',
|
||||
e => {
|
||||
if (!canHandleEvent()) return;
|
||||
|
||||
app.group();
|
||||
e.preventDefault();
|
||||
},
|
||||
undefined,
|
||||
[app]
|
||||
);
|
||||
|
||||
useHotkeys(
|
||||
'⌘+shift+g,ctrl+shift+g',
|
||||
e => {
|
||||
if (!canHandleEvent()) return;
|
||||
|
||||
app.ungroup();
|
||||
e.preventDefault();
|
||||
},
|
||||
undefined,
|
||||
[app]
|
||||
);
|
||||
|
||||
// Move
|
||||
|
||||
useHotkeys(
|
||||
'[',
|
||||
() => {
|
||||
if (!canHandleEvent(true)) return;
|
||||
app.moveBackward();
|
||||
},
|
||||
undefined,
|
||||
[app]
|
||||
);
|
||||
|
||||
useHotkeys(
|
||||
']',
|
||||
() => {
|
||||
if (!canHandleEvent(true)) return;
|
||||
app.moveForward();
|
||||
},
|
||||
undefined,
|
||||
[app]
|
||||
);
|
||||
|
||||
useHotkeys(
|
||||
'shift+[',
|
||||
() => {
|
||||
if (!canHandleEvent(true)) return;
|
||||
app.moveToBack();
|
||||
},
|
||||
undefined,
|
||||
[app]
|
||||
);
|
||||
|
||||
useHotkeys(
|
||||
'shift+]',
|
||||
() => {
|
||||
if (!canHandleEvent(true)) return;
|
||||
app.moveToFront();
|
||||
},
|
||||
undefined,
|
||||
[app]
|
||||
);
|
||||
|
||||
useHotkeys(
|
||||
'ctrl+shift+backspace,⌘+shift+backspace',
|
||||
e => {
|
||||
if (!canHandleEvent()) return;
|
||||
if (app.settings.isDebugMode) {
|
||||
app.resetDocument();
|
||||
}
|
||||
e.preventDefault();
|
||||
},
|
||||
undefined,
|
||||
[app]
|
||||
);
|
||||
|
||||
// Text Align
|
||||
|
||||
useHotkeys(
|
||||
'alt+command+l,alt+ctrl+l',
|
||||
e => {
|
||||
if (!canHandleEvent(true)) return;
|
||||
app.style({ textAlign: AlignStyle.Start });
|
||||
e.preventDefault();
|
||||
},
|
||||
undefined,
|
||||
[app]
|
||||
);
|
||||
|
||||
useHotkeys(
|
||||
'alt+command+t,alt+ctrl+t',
|
||||
e => {
|
||||
if (!canHandleEvent(true)) return;
|
||||
app.style({ textAlign: AlignStyle.Middle });
|
||||
e.preventDefault();
|
||||
},
|
||||
undefined,
|
||||
[app]
|
||||
);
|
||||
|
||||
useHotkeys(
|
||||
'alt+command+r,alt+ctrl+r',
|
||||
e => {
|
||||
if (!canHandleEvent(true)) return;
|
||||
app.style({ textAlign: AlignStyle.End });
|
||||
e.preventDefault();
|
||||
},
|
||||
undefined,
|
||||
[app]
|
||||
);
|
||||
}
|
||||
60
libs/components/board-draw/src/hooks/use-stylesheet.ts
Normal file
60
libs/components/board-draw/src/hooks/use-stylesheet.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import * as React from 'react';
|
||||
|
||||
const styles = new Map<string, HTMLStyleElement>();
|
||||
|
||||
const UID = `tldraw-fonts`;
|
||||
|
||||
const CSS = `
|
||||
@import url('https://fonts.googleapis.com/css2?family=Caveat+Brush&family=Source+Code+Pro&family=Source+Sans+Pro&family=Crimson+Pro&display=block');
|
||||
|
||||
@font-face {
|
||||
font-family: 'Recursive';
|
||||
font-style: normal;
|
||||
font-weight: 500;
|
||||
font-display: swap;
|
||||
src: url(https://fonts.gstatic.com/s/recursive/v23/8vI-7wMr0mhh-RQChyHEH06TlXhq_gukbYrFMk1QuAIcyEwG_X-dpEfaE5YaERmK-CImKsvxvU-MXGX2fSqasNfUlTGZnI14ZeY.woff2)
|
||||
format('woff2');
|
||||
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC,
|
||||
U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Recursive';
|
||||
font-style: normal;
|
||||
font-weight: 700;
|
||||
font-display: swap;
|
||||
src: url(https://fonts.gstatic.com/s/recursive/v23/8vI-7wMr0mhh-RQChyHEH06TlXhq_gukbYrFMk1QuAIcyEwG_X-dpEfaE5YaERmK-CImKsvxvU-MXGX2fSqasNfUlTGZnI14ZeY.woff2)
|
||||
format('woff2');
|
||||
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC,
|
||||
U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Recursive Mono';
|
||||
font-style: normal;
|
||||
font-weight: 420;
|
||||
font-display: swap;
|
||||
src: url(https://fonts.gstatic.com/s/recursive/v23/8vI-7wMr0mhh-RQChyHEH06TlXhq_gukbYrFMk1QuAIcyEwG_X-dpEfaE5YaERmK-CImqvTxvU-MXGX2fSqasNfUlTGZnI14ZeY.woff2)
|
||||
format('woff2');
|
||||
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC,
|
||||
U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
||||
}
|
||||
`;
|
||||
|
||||
export function useStylesheet() {
|
||||
React.useLayoutEffect(() => {
|
||||
if (styles.get(UID)) return;
|
||||
const style = document.createElement('style');
|
||||
style.innerHTML = CSS;
|
||||
style.setAttribute('id', UID);
|
||||
document.head.appendChild(style);
|
||||
styles.set(UID, style);
|
||||
|
||||
return () => {
|
||||
if (style && document.head.contains(style)) {
|
||||
document.head.removeChild(style);
|
||||
styles.delete(UID);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
}
|
||||
9
libs/components/board-draw/src/hooks/use-tldraw-app.ts
Normal file
9
libs/components/board-draw/src/hooks/use-tldraw-app.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import * as React from 'react';
|
||||
import type { TldrawApp } from '@toeverything/components/board-state';
|
||||
|
||||
export const TldrawContext = React.createContext<TldrawApp>({} as TldrawApp);
|
||||
|
||||
export function useTldrawApp() {
|
||||
const context = React.useContext(TldrawContext);
|
||||
return context;
|
||||
}
|
||||
2
libs/components/board-draw/src/index.ts
Normal file
2
libs/components/board-draw/src/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { Tldraw } from './TlDraw';
|
||||
export { useTldrawApp } from './hooks';
|
||||
Reference in New Issue
Block a user