init: the first public commit for AFFiNE

This commit is contained in:
DarkSky
2022-07-22 15:49:21 +08:00
commit e3e3741393
1451 changed files with 108124 additions and 0 deletions

View 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,
});

View File

@@ -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>
);
};

View File

@@ -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',
});

View File

@@ -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>
);
};

View File

@@ -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>
);
};

View File

@@ -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,
}));

View File

@@ -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>
);
};

View File

@@ -0,0 +1 @@
export { CommandPanel } from './CommandPanel';

View File

@@ -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',
});

View File

@@ -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>
);
};

View File

@@ -0,0 +1 @@
export { StrokeLineStyleConfig } from './StrokeLineStyleConfig';

View File

@@ -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;
},
};
},
};
};

View File

@@ -0,0 +1,2 @@
export { getAnchor } from './get-anchor';
export { useConfig, getShapeIds } from './use-config';

View File

@@ -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);
};

View File

@@ -0,0 +1,5 @@
import type { FC, ReactNode } from 'react';
export const ContextMenu: FC<{ children: ReactNode }> = ({ children }) => {
return <div>{children}</div>;
};

View File

@@ -0,0 +1 @@
export * from './context-menu';

View File

@@ -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',
},
});

View File

@@ -0,0 +1 @@
export * from './error-fallback';

View File

@@ -0,0 +1 @@
export * from './loading';

View File

@@ -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%)`,
},
},
},
});

View File

@@ -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,
}));

View File

@@ -0,0 +1 @@
export { Palette } from './Palette';

View File

@@ -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',
});

View File

@@ -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',
});

View File

@@ -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',
},
});

View File

@@ -0,0 +1 @@
export { ToolsPanel } from './ToolsPanel';

View File

@@ -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',
};
}
);

View File

@@ -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',
});

View File

@@ -0,0 +1 @@
export { PenTools } from './PenTools';

View File

@@ -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',
});

View File

@@ -0,0 +1 @@
export { ZoomBar } from './ZoomBar';

View File

@@ -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`,
});

View File

@@ -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,
},
}));

View File

@@ -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),
},
})
);

View File

@@ -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,
};
};

View File

@@ -0,0 +1 @@
export { MiniMap } from './MiniMap';

View 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'];

View 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';

View File

@@ -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,
};
}

View 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]
);
}

View 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);
}
};
}, []);
}

View 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;
}

View File

@@ -0,0 +1,2 @@
export { Tldraw } from './TlDraw';
export { useTldrawApp } from './hooks';