Compare commits

...

22 Commits

Author SHA1 Message Date
2f92b3b883 Small UX enhancements
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2023-12-30 14:23:43 +00:00
e51f3af984 Remove use of Drift from code 2023-12-30 14:13:49 +00:00
1e85112f6d Improved mobile navigation 2023-12-30 14:12:04 +00:00
c3598dfa5c Improved project breadcrumbs 2023-12-30 14:09:21 +00:00
3aa70c48e7 Disable draft tour 2023-12-30 12:21:03 +00:00
c368e675b4 Improved UI 2023-12-30 12:10:23 +00:00
68351f84e3 Allow for downloading patterns from editor 2023-12-30 12:05:07 +00:00
e46d1c63a7 Update method for generating previews 2023-12-30 11:48:49 +00:00
29aba03ba8 Add support for viewing back of cloth 2023-12-30 10:44:27 +00:00
e387c0e05a Remove canvas repositioning based on viewing back 2023-12-30 10:39:53 +00:00
164ca73913 Back view for warp/weft/tieups 2023-12-30 10:16:16 +00:00
d70858ffb7 Colour layout improvements 2023-12-30 09:41:26 +00:00
073335a322 Controls for choosing front or back view 2023-12-30 00:04:34 +00:00
450efe8b36 Add support for eraser tool 2023-12-29 23:44:03 +00:00
ba1ba5ed94 Improved tools UI 2023-12-29 23:30:28 +00:00
80cf4d3b4c Modal based nav prompt for unsaved drafts 2023-12-29 23:02:04 +00:00
79299ab978 Improved user-less routing 2023-12-29 22:51:19 +00:00
46e2f76778 Update to use react router data APIs 2023-12-29 22:15:55 +00:00
a00de971ae More improvements 2023-12-27 15:15:08 +00:00
8eb4eb4bd9 Sticky toolbox 2023-12-27 15:03:56 +00:00
78c3908bf9 Tools improvements 2023-12-27 14:53:45 +00:00
c394de8286 New tools view init 2023-12-27 14:30:56 +00:00
31 changed files with 596 additions and 449 deletions

View File

@ -53,6 +53,7 @@ def copy_to_project(user, id, project_id):
obj['project'] = target_project['_id'] obj['project'] = target_project['_id']
obj['createdAt'] = datetime.datetime.now() obj['createdAt'] = datetime.datetime.now()
obj['commentCount'] = 0 obj['commentCount'] = 0
if 'preview' in obj: del obj['preview']
db.objects.insert_one(obj) db.objects.insert_one(obj)
return obj return obj

Binary file not shown.

Binary file not shown.

View File

@ -23,7 +23,7 @@
"react-joyride": "^2.4.0", "react-joyride": "^2.4.0",
"react-redux": "^8.0.1", "react-redux": "^8.0.1",
"react-rewards": "^2.0.3", "react-rewards": "^2.0.3",
"react-router-dom": "^6.3.0", "react-router-dom": "^6.21.1",
"react-toastify": "^4.4.0", "react-toastify": "^4.4.0",
"redux": "^4.0.0", "redux": "^4.0.0",
"sanitize-html": "^1.19.1", "sanitize-html": "^1.19.1",

View File

@ -3,8 +3,6 @@ import { store } from '../index';
export default { export default {
INIT_DRIFT: 'INIT_DRIFT',
SYNC_DRIFT: 'SYNC_DRIFT',
REQUEST_USERS: 'REQUEST_USERS', REQUEST_USERS: 'REQUEST_USERS',
REQUEST_FAILED: 'REQUEST_FAILED', REQUEST_FAILED: 'REQUEST_FAILED',
RECEIVE_USER: 'RECEIVE_USERS', RECEIVE_USER: 'RECEIVE_USERS',
@ -15,14 +13,6 @@ export default {
LEAVE_GROUP: 'LEAVE_GROUP', LEAVE_GROUP: 'LEAVE_GROUP',
UPDATE_SUBSCRIPTIONS: 'UPDATE_SUBSCRIPTIONS', UPDATE_SUBSCRIPTIONS: 'UPDATE_SUBSCRIPTIONS',
initDrift() {
return { type: this.INIT_DRIFT };
},
syncDrift(synced) {
return { type: this.SYNC_DRIFT, synced };
},
request() { request() {
return { type: this.REQUEST_USERS }; return { type: this.REQUEST_USERS };
}, },

View File

@ -1,5 +1,5 @@
import React, { useEffect } from 'react'; import React, { useEffect } from 'react';
import { Routes, Route } from 'react-router-dom'; import { Outlet } from 'react-router-dom';
import { Helmet } from 'react-helmet'; import { Helmet } from 'react-helmet';
import { useDispatch, useSelector } from 'react-redux'; import { useDispatch, useSelector } from 'react-redux';
import { ToastContainer, toast } from 'react-toastify'; import { ToastContainer, toast } from 'react-toastify';
@ -11,47 +11,7 @@ import actions from '../actions';
import utils from '../utils/utils.js'; import utils from '../utils/utils.js';
import NavBar from './includes/NavBar'; import NavBar from './includes/NavBar';
import Footer from './includes/Footer'; import Footer from './includes/Footer';
import MarketingHome from './marketing/Home';
import PrivacyPolicy from './marketing/PrivacyPolicy';
import TermsOfUse from './marketing/TermsOfUse';
import Login from './Login'; import Login from './Login';
import ForgottenPassword from './ForgottenPassword';
import ResetPassword from './ResetPassword';
import Home from './main/Home';
import Profile from './main/users/Profile';
import ProfileEdit from './main/users/EditProfile';
import ProfileProjects from './main/users/ProfileProjects';
import NewProject from './main/projects/New';
import Project from './main/projects/Project';
import ProjectObjects from './main/projects/ProjectObjects';
import ProjectSettings from './main/projects/Settings';
import ObjectDraft from './main/projects/objects/Draft';
import ObjectList from './main/projects/ObjectList';
import Settings from './main/settings/Settings';
import SettingsIdentity from './main/settings/Identity';
import SettingsNotification from './main/settings/Notification';
import SettingsAccount from './main/settings/Account';
import NewGroup from './main/groups/New';
import Group from './main/groups/Group';
import GroupFeed from './main/groups/Feed';
import GroupMembers from './main/groups/Members';
import GroupProjects from './main/groups/Projects';
import GroupSettings from './main/groups/Settings';
import Explore from './main/explore/Explore'
import Docs from './docs';
import DocsHome from './docs/Home';
import DocsDoc from './docs/Doc';
import Root from './main/root';
const StyledContent = styled.div` const StyledContent = styled.div`
display: flex; display: flex;
@ -67,11 +27,10 @@ const GlobalStyle = createGlobalStyle`
function App() { function App() {
const dispatch = useDispatch(); const dispatch = useDispatch();
const { isAuthenticated, isAuthenticating, isAuthenticatingType, user, driftReady, syncedToDrift } = useSelector(state => { const { isAuthenticated, isAuthenticating, isAuthenticatingType, user } = useSelector(state => {
const user = state.users.users.filter(u => state.auth.currentUserId === u._id)[0]; const user = state.users.users.filter(u => state.auth.currentUserId === u._id)[0];
const { isAuthenticated, isAuthenticating, isAuthenticatingType } = state.auth; const { isAuthenticated, isAuthenticating, isAuthenticatingType } = state.auth;
const { driftReady, syncedToDrift } = state.users; return { isAuthenticated, isAuthenticating, isAuthenticatingType, user };
return { isAuthenticated, isAuthenticating, isAuthenticatingType, user, driftReady, syncedToDrift };
}); });
const loggedInUserId = user?._id; const loggedInUserId = user?._id;
@ -94,74 +53,17 @@ function App() {
}); });
}, [dispatch, loggedInUserId]); }, [dispatch, loggedInUserId]);
useEffect(() => {
window.drift && window.drift.on('ready', () => {
dispatch(actions.users.initDrift());
});
}, [dispatch]);
useEffect(() => {
if (user && driftReady && !syncedToDrift && window.drift) {
window.drift.identify(user._id, {
email: user.email,
username: user.username,
createdAt: user.createdAt,
});
dispatch(actions.users.syncDrift(null));
}
}, [dispatch, user, driftReady, syncedToDrift]);
return ( return (
<StyledContent> <StyledContent>
<GlobalStyle whiteColor /> <GlobalStyle whiteColor />
<Helmet defaultTitle={utils.appName()} titleTemplate={`%s | ${utils.appName()}`} /> <Helmet defaultTitle={utils.appName()} titleTemplate={`%s | ${utils.appName()}`} />
<NavBar /> <NavBar />
<div style={{ flex: '1' }}> <div style={{ flex: '1' }}>
<Routes> <Outlet />
<Route end path="/" element={isAuthenticated </div>
? <Home />
: <MarketingHome onRegisterClicked={() => dispatch(actions.auth.openRegister())} />
} />
<Route path="/privacy" element={<PrivacyPolicy />} />
<Route path="/terms-of-use" element={<TermsOfUse />} />
<Route path="/password/forgotten" element={<ForgottenPassword />} />
<Route path="/password/reset" element={<ResetPassword />} />
<Route path="/settings" element={<Settings />}>
<Route path='identity' element={<SettingsIdentity />} />
<Route path='notifications' element={<SettingsNotification />} />
<Route path='account' element={<SettingsAccount />} />
<Route path='' element={<SettingsIdentity />} />
</Route>
<Route path="/projects/new" element={<NewProject />} />
<Route path="/groups/new" element={<NewGroup />} />
<Route path="/groups/:id" element={<Group />}>
<Route path='feed' element={<GroupFeed />} />
<Route path='members' element={<GroupMembers />} />
<Route path='projects' element={<GroupProjects />} />
<Route path='settings' element={<GroupSettings />} />
<Route path='' end element={<GroupFeed />} />
</Route>
<Route path='/root' element={<Root />} />
<Route path='/docs' element={<Docs />}>
<Route path=":doc" element={<DocsDoc />} />
<Route path='' element={<DocsHome />} />
</Route>
<Route path='/:username/:projectPath' element={<Project />}>
<Route path="settings" element={<ProjectSettings />} />
<Route path=":objectId/edit" element={<ObjectDraft />} />
<Route path=":objectId" element={<ProjectObjects />} />
<Route path='' element={<ObjectList />} />
</Route>
<Route path='/explore' element={<Explore />} />
<Route path="/:username" element={<Profile />}>
<Route path="edit" element={<ProfileEdit />} />
<Route path='' element={<ProfileProjects />} />
</Route>
</Routes>
<Login open={isAuthenticating} authType={isAuthenticatingType} onClose={() => dispatch(actions.auth.closeAuthentication())} /> <Login open={isAuthenticating} authType={isAuthenticatingType} onClose={() => dispatch(actions.auth.closeAuthentication())} />
<ToastContainer position={toast.POSITION.BOTTOM_CENTER} hideProgressBar/> <ToastContainer position={toast.POSITION.BOTTOM_CENTER} hideProgressBar/>
<Divider hidden section /> <Divider hidden section />
</div>
<Footer /> <Footer />
</StyledContent> </StyledContent>
); );

View File

@ -3,7 +3,7 @@ import {
Message, Modal, Grid, Form, Input, Button, Message, Modal, Grid, Form, Input, Button,
} from 'semantic-ui-react'; } from 'semantic-ui-react';
import { useDispatch, useSelector } from 'react-redux'; import { useDispatch, useSelector } from 'react-redux';
import { Link } from 'react-router-dom'; import { Link, useNavigate, useLocation } from 'react-router-dom';
import actions from '../actions'; import actions from '../actions';
import { api } from '../api'; import { api } from '../api';
import utils from '../utils/utils.js'; import utils from '../utils/utils.js';
@ -16,6 +16,8 @@ function Login({ open, authType, onClose }) {
const [password, setPassword] = useState(''); const [password, setPassword] = useState('');
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const dispatch = useDispatch(); const dispatch = useDispatch();
const navigate = useNavigate();
const location = useLocation();
const { error } = useSelector(state => { const { error } = useSelector(state => {
const { loading, error } = state.auth; const { loading, error } = state.auth;
@ -31,6 +33,7 @@ function Login({ open, authType, onClose }) {
setUsername(''); setUsername('');
dispatch(actions.auth.receiveLogin(data)); dispatch(actions.auth.receiveLogin(data));
onClose(); onClose();
if (location.pathname === '/') navigate('/projects');
}, (err) => { }, (err) => {
dispatch(actions.auth.loginError(err)); dispatch(actions.auth.loginError(err));
setLoading(false); setLoading(false);
@ -46,6 +49,7 @@ function Login({ open, authType, onClose }) {
setUsername(''); setUsername('');
dispatch(actions.auth.receiveLogin(data)); dispatch(actions.auth.receiveLogin(data));
onClose(); onClose();
if (location.pathname === '/') navigate('/projects');
}, (err) => { }, (err) => {
dispatch(actions.auth.loginError(err)); dispatch(actions.auth.loginError(err));
setLoading(false); setLoading(false);

View File

@ -0,0 +1,24 @@
import React from 'react';
import { Container, Segment, Button, Message } from 'semantic-ui-react';
import { useDispatch } from 'react-redux';
import utils from '../../utils/utils.js';
import actions from '../../actions';
export default function LoginNeeded() {
const dispatch = useDispatch();
return (
<Container style={{marginTop: 30}}>
<Message info>
<h3>Welcome to {utils.appName()}</h3>
<p>You need to have a {utils.appName()} account in order to access this. Please login or register first.</p>
<Button.Group style={{marginTop: 30}}>
<Button color="white" onClick={() => dispatch(actions.auth.openLogin())}>Login</Button>
<Button color="teal" onClick={() => dispatch(actions.auth.openRegister())}>Register</Button>
</Button.Group>
</Message>
</Container>
);
}

View File

@ -51,8 +51,6 @@ export default function NavBar() {
const logout = () => api.auth.logout(() => { const logout = () => api.auth.logout(() => {
dispatch(actions.auth.logout()); dispatch(actions.auth.logout());
dispatch(actions.users.syncDrift(false))
if (window.drift) window.drift.reset();
navigate('/'); navigate('/');
}); });
const isSupporter = user?.isSilverSupporter || user?.isGoldSupporter; const isSupporter = user?.isSilverSupporter || user?.isGoldSupporter;
@ -60,10 +58,10 @@ export default function NavBar() {
return ( return (
<StyledNavBar> <StyledNavBar>
<Container style={{display:'flex', justifyContent: 'space-between', alignItems: 'center'}}> <Container style={{display:'flex', justifyContent: 'space-between', alignItems: 'center'}}>
<Link to="/"><img alt={`${utils.appName()} logo`} src={logo} className="logo" /></Link> <Link to={user ? '/projects' : '/'}><img alt={`${utils.appName()} logo`} src={logo} className="logo" /></Link>
<div style={{flex: 1}}> <div style={{flex: 1}}>
<Menu secondary> <Menu secondary>
<Menu.Item className='above-mobile' as={Link} to='/' name='home' active={location.pathname === '/'} /> <Menu.Item className='above-mobile' as={Link} to='/projects' name='projects' active={location.pathname === '/projects'} />
<Menu.Item className='above-mobile' as={Link} to='/explore' name='explore' active={location.pathname === '/explore'} /> <Menu.Item className='above-mobile' as={Link} to='/explore' name='explore' active={location.pathname === '/explore'} />
<Menu.Item className='above-mobile' active={location.pathname.startsWith('/groups')} name='Groups'> <Menu.Item className='above-mobile' active={location.pathname.startsWith('/groups')} name='Groups'>
<Dropdown pointing='top left' icon={null} <Dropdown pointing='top left' icon={null}
@ -115,7 +113,7 @@ export default function NavBar() {
<Menu.Menu position='right'> <Menu.Menu position='right'>
{isAuthenticated && <> {isAuthenticated && <>
<Menu.Item className='above-mobile'><SearchBar /></Menu.Item> <Menu.Item className='abovee-mobile'><SearchBar /></Menu.Item>
<Dropdown direction="left" pointing="top right" icon={null} style={{ marginTop: 10}} <Dropdown direction="left" pointing="top right" icon={null} style={{ marginTop: 10}}
trigger={<UserChip user={user} withoutLink avatarOnly />} trigger={<UserChip user={user} withoutLink avatarOnly />}
> >
@ -128,7 +126,8 @@ export default function NavBar() {
{user?.isGoldSupporter && <Dropdown.Header><SupporterBadge type='gold' /></Dropdown.Header>} {user?.isGoldSupporter && <Dropdown.Header><SupporterBadge type='gold' /></Dropdown.Header>}
{user?.isSilverSupporter && !user?.isGoldSupporter && <Dropdown.Header><SupporterBadge type='silver' /></Dropdown.Header>} {user?.isSilverSupporter && !user?.isGoldSupporter && <Dropdown.Header><SupporterBadge type='silver' /></Dropdown.Header>}
<Dropdown.Divider /> <Dropdown.Divider />
<Link to="/" className="item">Projects</Link> <Link to="/projects" className="item">My projects</Link>
<Link to="/explore" className="item">Explore</Link>
{user &&<Link to={`/${user.username}`} className="item">Profile</Link>} {user &&<Link to={`/${user.username}`} className="item">Profile</Link>}
<Link to="/settings" className="item">Settings</Link> <Link to="/settings" className="item">Settings</Link>
<Dropdown.Divider /> <Dropdown.Divider />

View File

@ -9,6 +9,7 @@ import actions from '../../actions';
import api from '../../api'; import api from '../../api';
import utils from '../../utils/utils.js'; import utils from '../../utils/utils.js';
import LoginNeeded from '../includes/LoginNeeded';
import UserChip from '../includes/UserChip'; import UserChip from '../includes/UserChip';
import HelpLink from '../includes/HelpLink'; import HelpLink from '../includes/HelpLink';
import ProjectCard from '../includes/ProjectCard'; import ProjectCard from '../includes/ProjectCard';
@ -131,7 +132,9 @@ function Home() {
<Grid.Column computer={11} className='joyride-projects'> <Grid.Column computer={11} className='joyride-projects'>
<div style={{display: 'flex', justifyContent: 'space-between', alignItems: 'center'}}> <div style={{display: 'flex', justifyContent: 'space-between', alignItems: 'center'}}>
<h2><Icon name='book' /> Your projects</h2> <h2><Icon name='book' /> Your projects</h2>
{user &&
<div><Button className='joyride-createProject' as={Link} to="/projects/new" color='teal' content='Create a project' icon='plus' /></div> <div><Button className='joyride-createProject' as={Link} to="/projects/new" color='teal' content='Create a project' icon='plus' /></div>
}
</div> </div>
<p>Projects contain the patterns and files that make up your creations. <p>Projects contain the patterns and files that make up your creations.
<HelpLink className='joyride-help' link={`/docs/projects`} text='Learn more about projects' marginLeft/> <HelpLink className='joyride-help' link={`/docs/projects`} text='Learn more about projects' marginLeft/>
@ -139,7 +142,11 @@ function Home() {
<Divider hidden /> <Divider hidden />
{loadingProjects && !projects?.length && {!user &&
<LoginNeeded />
}
{user && loadingProjects && !projects?.length &&
<Card.Group itemsPerRow={2} stackable> <Card.Group itemsPerRow={2} stackable>
<PatternLoader isCompact count={3} /> <PatternLoader isCompact count={3} />
</Card.Group> </Card.Group>

View File

@ -9,6 +9,7 @@ import api from '../../../api';
import utils from '../../../utils/utils.js'; import utils from '../../../utils/utils.js';
import HelpLink from '../../includes/HelpLink'; import HelpLink from '../../includes/HelpLink';
import LoginNeeded from '../../includes/LoginNeeded';
function NewGroup() { function NewGroup() {
const navigate = useNavigate(); const navigate = useNavigate();
@ -33,12 +34,7 @@ function NewGroup() {
} }
if (!user) { if (!user) {
return ( return (<LoginNeeded />);
<Container style={{marginTop: 40}}>
<h1>Login required</h1>
<p>You need to have a {utils.appName()} account in order to create a group. Please register or login first.</p>
</Container>
);
} }
return ( return (

View File

@ -114,7 +114,7 @@ function ObjectList({ compact }) {
<div style={{flex: 1, marginLeft: 5}}> <div style={{flex: 1, marginLeft: 5}}>
<h3 style={{fontSize: 13, marginBottom: 0, wordBreak: 'break-all'}}>{object.name}</h3> <h3 style={{fontSize: 13, marginBottom: 0, wordBreak: 'break-all'}}>{object.name}</h3>
<Label size='mini' rounded> <Label size='mini' rounded>
{object.type === 'pattern' && <><Icon name='pencil' /> WIF pattern</>} {object.type === 'pattern' && <><Icon name='pencil' /> Weaving pattern</>}
{object.type === 'file' && {object.type === 'file' &&
(object.isImage ? <><Icon name='image' /> Image</> : <><Icon name='file outline' /> File</>) (object.isImage ? <><Icon name='image' /> Image</> : <><Icon name='file outline' /> File</>)
} }

View File

@ -83,27 +83,6 @@ function ObjectViewer() {
}); });
} }
const downloadDrawdownImage = (object) => {
const element = document.createElement('a');
element.setAttribute('href', object.previewUrl);
element.setAttribute('download', `${object.name.replace(/ /g, '_')}-drawdown.png`);
element.style.display = 'none';
document.body.appendChild(element);
element.click();
document.body.removeChild(element);
toast.info('The file has been downloaded');
}
const downloadPatternImage = (object) => {
const element = document.createElement('a');
element.setAttribute('href', object.patternImage);
element.setAttribute('download', `${object.name.replace(/ /g, '_')}-pattern.png`);
element.style.display = 'none';
document.body.appendChild(element);
element.click();
document.body.removeChild(element);
toast.info('The file has been downloaded');
}
const copyPattern = (project) => { const copyPattern = (project) => {
api.objects.copyTo(object._id, project._id, newObject => { api.objects.copyTo(object._id, project._id, newObject => {
window.location = `/${project.fullName}/${newObject._id}`; window.location = `/${project.fullName}/${newObject._id}`;
@ -125,15 +104,15 @@ function ObjectViewer() {
<div style={{ display: 'flex', justifyContent: 'end' }}> <div style={{ display: 'flex', justifyContent: 'end' }}>
{object.type === 'pattern' && (utils.canEditProject(user, project) || project.openSource || object.previewUrl) && <> {object.type === 'pattern' && (utils.canEditProject(user, project) || project.openSource || object.previewUrl) && <>
<Dropdown direction='left' icon={null} trigger={<Button size='small' secondary icon='download' content='Download pattern' loading={downloading} disabled={downloading}/>}> <Dropdown direction='left' icon={null} trigger={<Button size='tiny' secondary icon='download' content='Download pattern' loading={downloading} disabled={downloading}/>}>
<Dropdown.Menu> <Dropdown.Menu>
{object.previewUrl && {object.previewUrl &&
<Dropdown.Item onClick={e => downloadDrawdownImage(object)} content='Download drawdown as an image' icon='file outline' /> <Dropdown.Item onClick={e => utils.downloadDrawdownImage(object)} content='Download drawdown as an image' icon='file outline' />
} }
{(utils.canEditProject(user, project) || project.openSource) && {(utils.canEditProject(user, project) || project.openSource) &&
<React.Fragment> <React.Fragment>
{object.patternImage && {object.patternImage &&
<Dropdown.Item icon='file outline' content='Download complete pattern as an image' onClick={e => downloadPatternImage(object)}/> <Dropdown.Item icon='file outline' content='Download complete pattern as an image' onClick={e => utils.downloadPatternImage(object)}/>
} }
<Dropdown.Divider /> <Dropdown.Divider />
<Dropdown.Item onClick={e => downloadWif(object)} content="Download pattern in WIF format" icon="text file" /> <Dropdown.Item onClick={e => downloadWif(object)} content="Download pattern in WIF format" icon="text file" />
@ -143,7 +122,7 @@ function ObjectViewer() {
</Dropdown> </Dropdown>
{user && {user &&
<Dropdown direction='left' icon={null} trigger={<Button size="small" icon="copy" secondary content="Copy to.." />}> <Dropdown direction='left' icon={null} trigger={<Button size="tiny" icon="copy" secondary content="Copy to.." />}>
<Dropdown.Menu> <Dropdown.Menu>
<Dropdown.Header>Select a project to copy this pattern to</Dropdown.Header> <Dropdown.Header>Select a project to copy this pattern to</Dropdown.Header>
{myProjects?.map(myProject => <Dropdown.Item content={myProject.name} onClick={e => copyPattern(myProject)} />)} {myProjects?.map(myProject => <Dropdown.Item content={myProject.name} onClick={e => copyPattern(myProject)} />)}
@ -156,15 +135,17 @@ function ObjectViewer() {
{utils.canEditProject(user, project) && {utils.canEditProject(user, project) &&
<> <>
{object.type === 'pattern' {object.type === 'pattern'
&& <Button size="small" icon="pencil" primary content="Edit pattern" as={Link} to={`/${fullProjectPath}/${object._id}/edit`} /> && <Button size="tiny" icon="pencil" primary content="Edit pattern" as={Link} to={`/${fullProjectPath}/${object._id}/edit`} />
} }
<Dropdown <Dropdown
icon={null} icon={null}
direction='left' direction='left'
trigger={<Button size="small" icon="cogs" content="Options" />} trigger={<Button size="tiny" icon="cogs" content="Options" />}
> >
<Dropdown.Menu> <Dropdown.Menu>
<Dropdown.Item onClick={e => regeneratePreview(object)} content="Regenerate preview" icon="refresh" /> {object.type === 'pattern'
&& <Dropdown.Item onClick={e => regeneratePreview(object)} content="Regenerate preview" icon="refresh" />
}
<Dropdown.Item onClick={e => deleteObject(object)} content="Delete" icon="trash" /> <Dropdown.Item onClick={e => deleteObject(object)} content="Delete" icon="trash" />
</Dropdown.Menu> </Dropdown.Menu>
</Dropdown> </Dropdown>

View File

@ -1,6 +1,6 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import { Helmet } from 'react-helmet'; import { Helmet } from 'react-helmet';
import { Message, Form, TextArea, Container, Button, Icon, Grid, Card } from 'semantic-ui-react'; import { Message, Form, TextArea, Container, Button, Icon, Grid, Card, Breadcrumb } from 'semantic-ui-react';
import { Outlet, Link, useParams, useLocation } from 'react-router-dom'; import { Outlet, Link, useParams, useLocation } from 'react-router-dom';
import { useDispatch, useSelector } from 'react-redux'; import { useDispatch, useSelector } from 'react-redux';
import utils from '../../../utils/utils.js'; import utils from '../../../utils/utils.js';
@ -14,13 +14,14 @@ import FormattedMessage from '../../includes/FormattedMessage';
function Project() { function Project() {
const [descriptionExpanded, setDescriptionExpanded] = useState(false); const [descriptionExpanded, setDescriptionExpanded] = useState(false);
const { username, projectPath } = useParams(); const { username, projectPath, objectId } = useParams();
const dispatch = useDispatch(); const dispatch = useDispatch();
const location = useLocation(); const location = useLocation();
const { user, project, fullName, errorMessage, editingDescription } = useSelector(state => { const { user, project, fullName, errorMessage, editingDescription, object } = useSelector(state => {
const project = state.projects.projects.filter(p => p.path === projectPath && p.owner && p.owner.username === username)[0]; const project = state.projects.projects.filter(p => p.path === projectPath && p.owner && p.owner.username === username)[0];
const user = state.users.users.filter(u => state.auth.currentUserId === u._id)[0]; const user = state.users.users.filter(u => state.auth.currentUserId === u._id)[0];
return { user, project, fullName: `${username}/${projectPath}`, errorMessage: state.projects.errorMessage, editingDescription: state.projects.editingDescription }; const object = state.objects.objects.filter(o => o._id === objectId)[0];
return { user, project, object, fullName: `${username}/${projectPath}`, errorMessage: state.projects.errorMessage, editingDescription: state.projects.editingDescription };
}); });
useEffect(() => { useEffect(() => {
@ -55,16 +56,27 @@ function Project() {
&& ( && (
<div> <div>
{wideBody() && project.owner && {wideBody() && project.owner &&
<> <div style={{display: 'flex', alignItems: 'center'}}>
<h3 style={{ marginBottom: 0 }}>
{project.name}
<Button as={Link} size='tiny' style={{marginLeft: 10}} to={`/${project.fullName}`} basic secondary icon='arrow left' content='Back to project' />
</h3>
<UserChip user={project.owner} /> <UserChip user={project.owner} />
</> <Breadcrumb size='large' style={{marginLeft: 20}}>
<Breadcrumb.Section as={Link} to={`/${project.fullName}`}>{project.name}</Breadcrumb.Section>
{location.pathname === `/${project.fullName}/settings` && <>
<Breadcrumb.Divider icon='right chevron' />
<Breadcrumb.Section>Settings</Breadcrumb.Section>
</>}
{object && <>
<Breadcrumb.Divider icon='right chevron' />
<Breadcrumb.Section as={Link} to={`/${project.fullName}/${object._id}`}>{object.name}</Breadcrumb.Section>
{location.pathname === `/${project.fullName}/${object._id}/edit` && <>
<Breadcrumb.Divider icon='right chevron' />
<Breadcrumb.Section>Edit</Breadcrumb.Section>
</>}
</>}
</Breadcrumb>
</div>
} }
<Grid stackable style={{marginTop: 30}}> <Grid stackable style={{marginTop: 10}}>
{ !wideBody() && ( { !wideBody() && (
<Grid.Column computer={4} tablet={6}> <Grid.Column computer={4} tablet={6}>
<Card fluid raised> <Card fluid raised>

View File

@ -1,12 +1,13 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import { Modal, Button } from 'semantic-ui-react';
import { Helmet } from 'react-helmet'; import { Helmet } from 'react-helmet';
import { useSelector, useDispatch } from 'react-redux'; import { useSelector, useDispatch } from 'react-redux';
import { useParams } from 'react-router-dom'; import { useParams } from 'react-router-dom';
import { useBlocker } from 'react-router';
import { toast } from 'react-toastify'; import { toast } from 'react-toastify';
import styled from 'styled-components'; import styled from 'styled-components';
import Tour from '../../../includes/Tour';
import ElementPan from '../../../includes/ElementPan'; import ElementPan from '../../../includes/ElementPan';
import HelpLink from '../../../includes/HelpLink';
import Tour, { ReRunTour } from '../../../includes/Tour';
import util from '../../../../utils/utils.js'; import util from '../../../../utils/utils.js';
import Warp from './Warp'; import Warp from './Warp';
@ -39,11 +40,18 @@ function Draft() {
useEffect(() => { useEffect(() => {
api.objects.get(objectId, (o) => { api.objects.get(objectId, (o) => {
if (!o.pattern.baseSize) o.pattern.baseSize = 10; if (!o.pattern.baseSize) o.pattern.baseSize = 10;
dispatch(actions.objects.receive(o));
setObject(o); setObject(o);
setPattern(o.pattern); setPattern(o.pattern);
setTimeout(() => generatePreviews(o), 1000);
}); });
}, [objectId]); }, [objectId]);
const blocker = useBlocker( ({ currentLocation, nextLocation }) =>
unsaved &&
currentLocation.pathname !== nextLocation.pathname
);
const updateObject = (update) => { const updateObject = (update) => {
setObject(Object.assign({}, object, update)); setObject(Object.assign({}, object, update));
setUnsaved(true); setUnsaved(true);
@ -55,13 +63,20 @@ function Draft() {
setUnsaved(true); setUnsaved(true);
}; };
const generatePreviews = (o) => {
util.generatePatternPreview(o, previewUrl => {
util.generateCompletePattern(o?.pattern, ``, patternImage => {
setObject(Object.assign({}, o, { previewUrl, patternImage }));
});
});
}
const saveObject = () => { const saveObject = () => {
setSaving(true); setSaving(true);
util.generatePatternPreview(object, previewUrl => { generatePreviews();
dispatch(actions.objects.update(objectId, 'previewUrl', previewUrl));
});
const newObject = Object.assign({}, object); const newObject = Object.assign({}, object);
newObject.pattern = pattern; newObject.pattern = pattern;
generatePreviews(newObject);
api.objects.update(objectId, newObject, (o) => { api.objects.update(objectId, newObject, (o) => {
toast.success('Pattern saved'); toast.success('Pattern saved');
dispatch(actions.objects.receive(o)); dispatch(actions.objects.receive(o));
@ -77,12 +92,12 @@ function Draft() {
const { warp, weft, tieups, baseSize } = pattern; const { warp, weft, tieups, baseSize } = pattern;
const cellStyle = { width: `${baseSize || 10}px`, height: `${baseSize || 10}px` }; const cellStyle = { width: `${baseSize || 10}px`, height: `${baseSize || 10}px` };
return ( return (
<div> <div style={{position: 'relative'}}>
<Helmet title={`${name || 'Weaving Draft'}`} /> <Helmet title={`${name || 'Weaving Draft'}`} />
<Tour id='pattern' run={true} />
<div style={{display: 'flex'}}>
<div style={{flex: 1, overflow: 'hidden'}}> <Tools warp={warp} weft={weft} object={object} pattern={pattern} updateObject={updateObject} updatePattern={updatePattern} saveObject={saveObject} baseSize={baseSize} unsaved={unsaved} saving={saving}/>
<div style={{overflow: 'hidden', zIndex: 10}}>
<ElementPan <ElementPan
disabled={!(editor?.tool === 'pan')} disabled={!(editor?.tool === 'pan')}
startX={5000} startX={5000}
@ -91,10 +106,9 @@ function Draft() {
<StyledPattern <StyledPattern
style={{ style={{
width: `${warp.threading?.length * baseSize + weft.treadles * baseSize + 20}px`, width: `${warp.threading?.length * baseSize + weft.treadles * baseSize + 20}px`,
height: '1000px', // `${warp.shafts * baseSize + weft.threads * baseSize + 20}px` height: `${warp.shafts * baseSize + weft.treadling?.length * baseSize + 20}px`
}} }}
> >
<Warp baseSize={baseSize} cellStyle={cellStyle} warp={warp} weft={weft} updatePattern={updatePattern} /> <Warp baseSize={baseSize} cellStyle={cellStyle} warp={warp} weft={weft} updatePattern={updatePattern} />
<Weft cellStyle={cellStyle} warp={warp} weft={weft} baseSize={baseSize} updatePattern={updatePattern} /> <Weft cellStyle={cellStyle} warp={warp} weft={weft} baseSize={baseSize} updatePattern={updatePattern} />
<Tieups cellStyle={cellStyle} warp={warp} weft={weft} tieups={tieups} updatePattern={updatePattern} baseSize={baseSize}/> <Tieups cellStyle={cellStyle} warp={warp} weft={weft} tieups={tieups} updatePattern={updatePattern} baseSize={baseSize}/>
@ -104,13 +118,20 @@ function Draft() {
</ElementPan> </ElementPan>
</div> </div>
<div style={{width: 300, marginLeft: 20}}> <Modal open={blocker.state === "blocked"} basic size="small">
<HelpLink className='joyride-help' link={`/docs/patterns#using-the-pattern-editor`} marginBottom/> <Modal.Header content='Your pattern has not been saved' />
<ReRunTour id='pattern' /> <Modal.Content>
<Tools warp={warp} weft={weft} object={object} pattern={pattern} updateObject={updateObject} updatePattern={updatePattern} saveObject={saveObject} baseSize={baseSize} unsaved={unsaved} saving={saving}/> Would you like to save your draft before leaving?
</div> </Modal.Content>
<Modal.Actions>
</div> <Button basic color="red" inverted onClick={() => blocker.proceed()}>
Leave without saving
</Button>
<Button color="green" inverted onClick={() => blocker.reset()}>
Return to editor
</Button>
</Modal.Actions>
</Modal>
</div> </div>
); );
} }

View File

@ -25,54 +25,24 @@ function DraftPreview({ object }) {
setLoading(false); setLoading(false);
if (o.pattern && o.pattern.warp) { if (o.pattern && o.pattern.warp) {
setPattern(o.pattern); setPattern(o.pattern);
// Generate images
setTimeout(() => {
// Generate the preview if not yet set (e.g. if from uploaded WIF) // Generate the preview if not yet set (e.g. if from uploaded WIF)
if (!o.previewUrl) { if (!o.previewUrl) {
setTimeout(() => {
util.generatePatternPreview(object, previewUrl => { util.generatePatternPreview(object, previewUrl => {
dispatch(actions.objects.update(objectId, 'previewUrl', previewUrl)); dispatch(actions.objects.update(objectId, 'previewUrl', previewUrl));
}); });
}, 1000);
} }
// Generate the entire pattern and store in memory
util.generateCompletePattern(o.pattern, `.preview-${objectId}`, image => {
dispatch(actions.objects.update(objectId, 'patternImage', image));
});
}, 1000);
} }
}, err => setLoading(false)); }, err => setLoading(false));
}, [dispatch, objectId]); }, [dispatch, objectId]);
const unifyCanvas = useCallback(() => {
if (!pattern) return;
const { warp, weft } = pattern;
setTimeout(() => {
const baseSize = 6;
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
canvas.width = warp.threading?.length * baseSize + weft.treadles * baseSize + 20;
canvas.height = warp.shafts * baseSize + weft.treadling?.length * baseSize + 20;
ctx.fillStyle = 'white';
ctx.fillRect(0, 0, canvas.width, canvas.height);
ctx.fillStyle = 'black';
const warpCanvas = document.querySelector(`.preview-${objectId} .warp-threads`);
const warpColourwayCanvas = document.querySelector(`.preview-${objectId} .warp-colourway`);
const weftCanvas = document.querySelector(`.preview-${objectId} .weft-threads`);
const weftColourwayCanvas = document.querySelector(`.preview-${objectId} .weft-colourway`);
const drawdownCanvas = document.querySelector(`.preview-${objectId} .drawdown`);
const tieupsCanvas = document.querySelector(`.preview-${objectId} .tieups`);
if (warpCanvas) {
ctx.drawImage(warpColourwayCanvas, canvas.width - warpCanvas.width - weft.treadles * baseSize - 20, 0);
ctx.drawImage(warpCanvas, canvas.width - warpCanvas.width - weft.treadles * baseSize - 20, 10);
ctx.drawImage(weftCanvas, canvas.width - 10 - weft.treadles * baseSize, warp.shafts * baseSize + 20);
ctx.drawImage(weftColourwayCanvas, canvas.width - 10, warp.shafts * baseSize + 20);
ctx.drawImage(tieupsCanvas, canvas.width - weft.treadles * baseSize - 10, 10);
ctx.drawImage(drawdownCanvas, canvas.width - drawdownCanvas.width - weft.treadles * baseSize - 20, warp.shafts * baseSize + 20);
setTimeout(() => {
const im = canvas.toDataURL('image/png')
if (im?.length > 20)
dispatch(actions.objects.update(objectId, 'patternImage', im));
}, 500);
}
}, 500);
}, [dispatch, objectId, pattern]);
useEffect(() => unifyCanvas(), [unifyCanvas]);
if (loading) return <Loader active />; if (loading) return <Loader active />;
if (!pattern) return null; if (!pattern) return null;
const { warp, weft, tieups } = pattern; const { warp, weft, tieups } = pattern;

View File

@ -3,9 +3,9 @@ import { useSelector } from 'react-redux';
import styled from 'styled-components'; import styled from 'styled-components';
import utils from '../../../../utils/utils'; import utils from '../../../../utils/utils';
const StyledDrawdown = styled.canvas` const DrawdownCanvas = styled.canvas`
position:absolute; position: absolute;
border:1px dashed rgb(70,70,70); border: 1px dashed rgb(70,70,70);
top: ${props => (props.warp.shafts * props.baseSize) + 20}px; top: ${props => (props.warp.shafts * props.baseSize) + 20}px;
right: ${props => (props.weft.treadles * props.baseSize) + 20}px; right: ${props => (props.weft.treadles * props.baseSize) + 20}px;
`; `;
@ -17,6 +17,7 @@ function Drawdown({ baseSize, warp, weft, tieups }) {
const drawdownRef = useRef(); const drawdownRef = useRef();
useEffect(() => paintDrawdown()); useEffect(() => paintDrawdown());
const { editor } = useSelector(state => ({ editor: state.objects.editor })); const { editor } = useSelector(state => ({ editor: state.objects.editor }));
const { viewingBack } = editor;
const getSquare = (thread, size, colour) => { const getSquare = (thread, size, colour) => {
const { view } = editor; const { view } = editor;
@ -75,7 +76,11 @@ function Drawdown({ baseSize, warp, weft, tieups }) {
if (proceed) { if (proceed) {
const weftColour = utils.rgb(weft.treadling[tread].colour || weft.defaultColour); const weftColour = utils.rgb(weft.treadling[tread].colour || weft.defaultColour);
const warpColour = utils.rgb(warp.threading[thread].colour || warp.defaultColour); const warpColour = utils.rgb(warp.threading[thread].colour || warp.defaultColour);
const threadType = tieup && tieup.filter(t => t <= warp.shafts).indexOf(shaft) > -1 ? 'warp' : 'weft'; let threadType = tieup?.filter(t => t <= warp.shafts).indexOf(shaft) > -1 ? 'warp' : 'weft';
if (viewingBack) {
if (threadType === 'warp') threadType = 'weft';
else threadType = 'warp';
}
const square = getSquare(threadType, baseSize, threadType === 'warp' ? warpColour : weftColour); const square = getSquare(threadType, baseSize, threadType === 'warp' ? warpColour : weftColour);
ctx.drawImage(square, canvas.width - (baseSize * (thread + 1)), tread * baseSize); ctx.drawImage(square, canvas.width - (baseSize * (thread + 1)), tread * baseSize);
@ -87,7 +92,7 @@ function Drawdown({ baseSize, warp, weft, tieups }) {
const warpThreads = warp.threading?.length || 0; const warpThreads = warp.threading?.length || 0;
const weftThreads = weft.treadling?.length || 0; const weftThreads = weft.treadling?.length || 0;
return ( return (
<StyledDrawdown ref={drawdownRef} className="drawdown joyride-drawdown" <DrawdownCanvas ref={drawdownRef} className="drawdown joyride-drawdown"
width={warpThreads * baseSize} width={warpThreads * baseSize}
height={weftThreads * baseSize} height={weftThreads * baseSize}
weft={weft} warp={warp} baseSize={baseSize} weft={weft} warp={warp} baseSize={baseSize}

View File

@ -1,15 +1,17 @@
import React, { useEffect, useRef } from 'react'; import React, { useEffect, useRef } from 'react';
import styled from 'styled-components'; import styled from 'styled-components';
import { useSelector } from 'react-redux';
const StyledTieups = styled.canvas` const StyledTieups = styled.canvas`
position:absolute; position: absolute;
top:10px; top: 10px;
right:10px; right: 10px;
`; `;
function Tieups({ cellStyle, warp, weft, tieups, updatePattern, baseSize }) { function Tieups({ cellStyle, warp, weft, tieups, updatePattern, baseSize }) {
useEffect(() => paintTieups()); useEffect(() => paintTieups());
const tieupRef = useRef(null); const tieupRef = useRef(null);
const { editor } = useSelector(state => ({ editor: state.objects.editor }));
const fillUpTo = (t, limit) => { const fillUpTo = (t, limit) => {
let i = t.length; let i = t.length;

View File

@ -1,13 +1,15 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import { import {
Confirm, Header, Select, Segment, Accordion, Grid, Icon, Input, Button, Popup, Checkbox Confirm, Header, Segment, Accordion, Grid, Icon, Input, Button, Popup, Checkbox, Dropdown
} from 'semantic-ui-react'; } from 'semantic-ui-react';
import { useSelector, useDispatch } from 'react-redux'; import { useSelector, useDispatch } from 'react-redux';
import { useNavigate, useParams } from 'react-router-dom'; import { useNavigate, useParams, Link } from 'react-router-dom';
import { toast } from 'react-toastify'; import { toast } from 'react-toastify';
import { SketchPicker } from 'react-color'; import { SketchPicker } from 'react-color';
import Slider from 'rc-slider'; import Slider from 'rc-slider';
import styled from 'styled-components'; import styled from 'styled-components';
import HelpLink from '../../../includes/HelpLink';
import Tour, { ReRunTour } from '../../../includes/Tour';
import 'rc-slider/assets/index.css'; import 'rc-slider/assets/index.css';
@ -15,15 +17,22 @@ import utils from '../../../../utils/utils.js';
import actions from '../../../../actions'; import actions from '../../../../actions';
import api from '../../../../api'; import api from '../../../../api';
const VIEWS = [
{ key: 1, value: 'interlacement', text: 'Interlacement' },
{ key: 2, value: 'colour', text: 'Colour only' },
{ key: 3, value: 'warp', text: 'Warp view' },
{ key: 4, value: 'weft', text: 'Weft view' },
];
const ColourSquare = styled.div` const ColourSquare = styled.div`
background-color: ${props => props.colour}; background-color: ${props => props.colour};
display:inline-block; display: ${props => props.small ? 'block' : 'inline-block'};
position:relative; position:relative;
box-shadow:0px 0px 3px rgba(0,0,0,0.1); box-shadow:0px 0px 3px rgba(0,0,0,0.1);
border-radius:2px; border-radius:2px;
width:15px; width: ${props => props.small ? '8' : '15'}px;
height:15px; height: ${props => props.small ? '8' : '15'}px;
margin:2px; margin: ${props => props.small ? '8' : '2'}px;
cursor:pointer; cursor:pointer;
top:0px; top:0px;
transition:top 0.1s; transition:top 0.1s;
@ -36,6 +45,11 @@ const ColourSquare = styled.div`
} }
`; `;
const ToolLabel = styled.div`
font-size: small;
font-weight: bold;
`;
function Tools({ object, pattern, warp, weft, unsaved, saving, baseSize, updatePattern, updateObject, saveObject }) { function Tools({ object, pattern, warp, weft, unsaved, saving, baseSize, updatePattern, updateObject, saveObject }) {
const [activeDrawers, setActiveDrawers] = useState(['properties', 'drawing', 'palette']); const [activeDrawers, setActiveDrawers] = useState(['properties', 'drawing', 'palette']);
const [deleteModalOpen, setDeleteModalOpen] = useState(false); const [deleteModalOpen, setDeleteModalOpen] = useState(false);
@ -44,13 +58,15 @@ function Tools({ object, pattern, warp, weft, unsaved, saving, baseSize, updateP
const navigate = useNavigate(); const navigate = useNavigate();
const dispatch = useDispatch(); const dispatch = useDispatch();
const { objectId, username, projectPath } = useParams(); const { objectId, username, projectPath } = useParams();
const { colours } = pattern;
const { project, editor } = useSelector(state => { const { user, project, editor } = useSelector(state => {
const user = state.users.users.filter(u => state.auth.currentUserId === u._id)[0];
let project = {}; let project = {};
state.projects.projects.forEach((p) => { state.projects.projects.forEach((p) => {
if (p.path === projectPath && p.owner && p.owner.username === username) project = p; if (p.path === projectPath && p.owner && p.owner.username === username) project = p;
}); });
return { project, editor: state.objects.editor }; return { user, project, editor: state.objects.editor };
}); });
useEffect(() => { useEffect(() => {
@ -62,6 +78,10 @@ function Tools({ object, pattern, warp, weft, unsaved, saving, baseSize, updateP
setSelectedThreadCount(selected); setSelectedThreadCount(selected);
}, [pattern]); }, [pattern]);
useEffect(() => {
if (colours?.length && !editor.colour) setColour(colours[colours.length - 1]);
}, [colours]);
const enableTool = (tool) => { const enableTool = (tool) => {
dispatch(actions.objects.updateEditor({ tool, colour: editor.colour })); dispatch(actions.objects.updateEditor({ tool, colour: editor.colour }));
}; };
@ -74,6 +94,10 @@ function Tools({ object, pattern, warp, weft, unsaved, saving, baseSize, updateP
dispatch(actions.objects.updateEditor({ view })); dispatch(actions.objects.updateEditor({ view }));
}; };
const setEditorViewingBack = (viewingBack) => {
dispatch(actions.objects.updateEditor({ viewingBack }));
};
const setName = (event) => { const setName = (event) => {
updateObject({ name: event.target.value }); updateObject({ name: event.target.value });
}; };
@ -177,50 +201,91 @@ function Tools({ object, pattern, warp, weft, unsaved, saving, baseSize, updateP
}; };
return ( return (
<div className="pattern-toolbox joyride-tools"> <div className="joyride-tools" style={{position: 'sticky', top: 10, zIndex: 20}}>
{selectedThreadCount > 0 && <Segment>
<Segment attached="top"> <div style={{display: 'flex', justifyContent: 'space-between', alignItems: 'center'}}>
{selectedThreadCount > 0 ?
<div>
<Header>{selectedThreadCount} threads selected</Header> <Header>{selectedThreadCount} threads selected</Header>
<Button basic onClick={deselectThreads}>De-select all</Button> <Button size='small' basic onClick={deselectThreads}>De-select all</Button>
<Button color='orange' onClick={deleteSelectedThreads}>Delete threads</Button> <Button size='small' color='orange' onClick={deleteSelectedThreads}>Delete threads</Button>
</Segment> </div>
} :
<div style={{display: 'flex', alignItems: 'end'}}>
{unsaved && <div>
<Segment attached> <ToolLabel>View</ToolLabel>
<Button fluid color="teal" icon="save" content="Save pattern" onClick={() => saveObject(/*this.refs.canvas*/)} loading={saving}/> <Popup hoverable on='click'
<Button style={{marginTop: 5}} fluid icon='refresh' content='Undo changes' onClick={revertChanges} /> trigger={<Button color='blue' size="tiny" icon="zoom" />}
</Segment> content={<div style={{width: 150}}>
} <h4>Zoom</h4>
<Segment attached>
<Accordion fluid>
<Accordion.Title active={drawerIsActive('view')} onClick={e => activateDrawer('view')}>
<Icon name="dropdown" /> View
</Accordion.Title>
<Accordion.Content active={drawerIsActive('view')}>
<small>Drawdown view</small>
<Select
size="tiny"
fluid
value={editor.view}
onChange={(e, s) => setEditorView(s.value)}
style={{ fontSize: '11px' }}
options={[
{ key: 1, value: 'interlacement', text: 'Interlacement' },
{ key: 2, value: 'colour', text: 'Colour only' },
{ key: 3, value: 'warp', text: 'Warp view' },
{ key: 4, value: 'weft', text: 'Weft view' },
]}
/>
<div style={{ marginTop: '5px' }} />
<small>Zoom</small>
<Slider defaultValue={baseSize} min={5} max={13} step={1} onAfterChange={onZoomChange} /> <Slider defaultValue={baseSize} min={5} max={13} step={1} onAfterChange={onZoomChange} />
</Accordion.Content> </div>}
/>
<Accordion.Title active={drawerIsActive('properties')} onClick={e => activateDrawer('properties')}> <small><Dropdown text={`${VIEWS.find(v => v.value === editor.view)?.text} (${editor.viewingBack ? 'Back' : 'Front'})`} size='tiny'>
<Icon name="dropdown" /> Properties <Dropdown.Menu>
</Accordion.Title> {VIEWS.map(view =>
<Accordion.Content active={drawerIsActive('properties')}> <Dropdown.Item key={view.value} text={view.text} onClick={e => setEditorView(view.value)} />
)}
<Dropdown.Divider />
<Dropdown.Item onClick={() => setEditorViewingBack(false)}>Front{!editor.viewingBack && <Icon style={{marginLeft: 5}} name='check' />}</Dropdown.Item>
<Dropdown.Item onClick={() => setEditorViewingBack(true)}>Back {editor.viewingBack && <Icon style={{marginLeft: 5}} name='check' />}</Dropdown.Item>
</Dropdown.Menu>
</Dropdown></small>
</div>
<div style={{marginLeft: 10}}>
<ToolLabel>Tools & drawing</ToolLabel>
<Button.Group size="tiny">
<Button className='joyride-pan' data-tooltip="Pan (drag to move) pattern" color={editor.tool === 'pan' && 'blue'} size="tiny" icon onClick={() => enableTool('pan')}><Icon name="move" /></Button>
<Button data-tooltip="Select threads" color={editor.tool === 'select' && 'blue'} size="tiny" icon onClick={() => enableTool('select')}><Icon name="i cursor" /></Button>
<Button data-tooltip="Insert threads" color={editor.tool === 'insert' && 'blue'} size="tiny" icon onClick={() => enableTool('insert')}><Icon name="plus" /></Button>
<Button className='joyride-colour' data-tooltip="Apply thread colour" color={editor.tool === 'colour' && 'blue'} size="tiny" icon onClick={() => enableTool('colour')}><Icon name="paint brush" /></Button>
<Button data-tooltip="Erase threads" color={editor.tool === 'eraser' && 'blue'} size="tiny" icon onClick={() => enableTool('eraser')}><Icon name="eraser" /></Button>
<Button className='joyride-straight' data-tooltip="Straight draw" color={editor.tool === 'straight' && 'blue'} size="tiny" icon onClick={() => enableTool('straight')}>/ /</Button>
<Button className='joyride-point' data-tooltip="Point draw" color={editor.tool === 'point' && 'blue'} size="tiny" icon onClick={() => enableTool('point')}><Icon name="chevron up" /></Button>
</Button.Group>
</div>
<div style={{marginLeft: 10}}>
<div style={{display: 'flex', 'alignItems': 'end'}}>
<ToolLabel>Colour</ToolLabel>
<div><ColourSquare small colour={utils.rgb(editor.colour)} style={{top: 4, marginLeft: 10}} /></div>
</div>
<Button.Group size="tiny">
<Popup hoverable on="click"
trigger={<Button style={{marginLeft: 5}} size="tiny" icon="tint" style={{color: utils.rgb(editor.colour)}} />}
content={<div style={{width: 150}}>
{pattern.colours && pattern.colours.map(colour =>
<ColourSquare key={colour} colour={utils.rgb(colour)} onClick={() => setColour(colour)} />
)}
</div>}
/>
<Popup hoverable on='click'
trigger={<Button color='blue' size='mini' icon='add' />}
content={
<div style={{padding: 3}}>
<SketchPicker color={newColour} onChangeComplete={c => setNewColour(c.rgb)} />
<Button size='sm' style={{marginTop: 10}} onClick={e => {
const { r, g, b } = newColour;
const newColours = Object.assign([], pattern.colours);
newColours.push(`${r},${g},${b}`);
updatePattern({ colours: newColours })
}}>Add colour to palette</Button>
</div>
}
/>
</Button.Group>
</div>
</div>
}
<div>
<div>
<Popup hoverable on='click' position='top right'
trigger={<Button color='blue' style={{marginLeft: 5}} size='tiny' content='Properties' />}
content={
<div style={{padding: 3, width: 300}}>
<small>Name</small> <small>Name</small>
<Input type="text" size="small" fluid style={{ marginBottom: '5px' }} value={object.name} onChange={setName} /> <Input type="text" size="small" fluid style={{ marginBottom: '5px' }} value={object.name} onChange={setName} />
<Grid columns={2}> <Grid columns={2}>
@ -253,62 +318,56 @@ function Tools({ object, pattern, warp, weft, unsaved, saving, baseSize, updateP
content='Add new threads to the warp and weft as you edit near the end' content='Add new threads to the warp and weft as you edit near the end'
trigger={<Checkbox checked={editor.autoExtend ?? true} onChange={setAutomaticallyExtend} label='Auto-extend warp and weft' style={{marginTop: 10}} />} trigger={<Checkbox checked={editor.autoExtend ?? true} onChange={setAutomaticallyExtend} label='Auto-extend warp and weft' style={{marginTop: 10}} />}
/> />
</Accordion.Content>
<Accordion.Title active={drawerIsActive('drawing')} onClick={e => activateDrawer('drawing')}> <div style={{marginTop: 20}}>
<Icon name="dropdown" /> Tools <Button basic color='red' fluid onClick={e => setDeleteModalOpen(true)}>Delete pattern</Button>
</Accordion.Title> </div>
<Accordion.Content active={drawerIsActive('drawing')}>
<Button.Group fluid>
<Button className='joyride-pan' data-tooltip="Pan (drag to move) pattern" color={editor.tool === 'pan' && 'blue'} size="tiny" icon onClick={() => enableTool('pan')}><Icon name="move" /></Button>
<Button data-tooltip="Select threads" color={editor.tool === 'select' && 'blue'} size="tiny" icon onClick={() => enableTool('select')}><Icon name="i cursor" /></Button>
<Button data-tooltip="Insert threads" color={editor.tool === 'insert' && 'blue'} size="tiny" icon onClick={() => enableTool('insert')}><Icon name="plus" /></Button>
<Button className='joyride-colour' data-tooltip="Apply thread colour" color={editor.tool === 'colour' && 'blue'} size="tiny" icon onClick={() => enableTool('colour')}><Icon name="paint brush" /></Button>
<Button className='joyride-straight' data-tooltip="Straight draw" color={editor.tool === 'straight' && 'blue'} size="tiny" icon onClick={() => enableTool('straight')}>/ /</Button>
<Button className='joyride-point' data-tooltip="Point draw" color={editor.tool === 'point' && 'blue'} size="tiny" icon onClick={() => enableTool('point')}><Icon name="chevron up" /></Button>
</Button.Group>
</Accordion.Content>
<Accordion.Title active={drawerIsActive('palette')} onClick={e => activateDrawer('palette')}>
<Icon name="dropdown" /> Palette
<ColourSquare colour={utils.rgb(editor.colour)} style={{top: 4, marginLeft: 10}} />
</Accordion.Title>
<Accordion.Content active={drawerIsActive('palette')}>
{pattern.colours && pattern.colours.map(colour =>
<ColourSquare key={colour} colour={utils.rgb(colour)} onClick={() => setColour(colour)} />
)}
<div style={{marginTop: 10}}>
<Popup
trigger={<Button size='mini'><Icon name='add' /> Colour</Button>}
on='click' hoverable
content={
<div style={{padding: 5}}>
<SketchPicker color={newColour} onChangeComplete={c => setNewColour(c.rgb)} />
<Button size='sm' style={{marginTop: 10}} onClick={e => {
const { r, g, b } = newColour;
const newColours = Object.assign([], pattern.colours);
newColours.push(`${r},${g},${b}`);
updatePattern({ colours: newColours })
}}>Add colour to palette</Button>
</div> </div>
} }
/> />
</div>
</Accordion.Content>
<Accordion.Title active={drawerIsActive('advanced')} onClick={e => activateDrawer('advanced')}>
<Icon name="dropdown" /> Advanced
</Accordion.Title>
<Accordion.Content active={drawerIsActive('advanced')}>
<Button size="small" basic color="red" fluid onClick={e => setDeleteModalOpen(true)}>Delete pattern</Button>
<Confirm <Confirm
open={deleteModalOpen} open={deleteModalOpen}
content="Really delete this pattern?" content="Really delete this pattern?"
onCancel={e => setDeleteModalOpen(false)} onCancel={e => setDeleteModalOpen(false)}
onConfirm={deleteObject} onConfirm={deleteObject}
/> />
</Accordion.Content>
</Accordion> <Dropdown icon={null} direction='left' disabled={unsaved}
trigger={<Button color='blue' size='tiny' icon='download' style={{marginLeft: 5}} />}
>
<Dropdown.Menu>
{object.previewUrl &&
<Dropdown.Item onClick={e => utils.downloadDrawdownImage(object)} content='Download drawdown as an image' icon='file outline' />
}
{(utils.canEditProject(user, project) || project.openSource) &&
<>
{object.patternImage &&
<Dropdown.Item icon='file outline' content='Download complete pattern as an image' onClick={e => utils.downloadPatternImage(object)}/>
}
<Dropdown.Divider />
<Dropdown.Item onClick={e => utils.downloadWif(object)} content="Download pattern in WIF format" icon="text file" />
</>
}
</Dropdown.Menu>
</Dropdown>
<Dropdown style={{marginLeft: 5}} icon={null} direction='left'
trigger={<Button basic size='tiny' icon='help' />}
>
<Dropdown.Menu>
<Dropdown.Item as={Link} to={`/docs/patterns#using-the-pattern-editor`}target="_blank">Documentation</Dropdown.Item>
</Dropdown.Menu>
</Dropdown>
</div>
<div style={{marginTop: 5, textAlign: 'right'}}>
<Button.Group size="tiny">
<Button size="tiny" color="teal" icon="save" content="Save" onClick={() => saveObject()} loading={saving} disabled={!unsaved} />
<Button size="tiny" color="orange" icon='refresh' content='Revert' onClick={revertChanges} disabled={!unsaved} />
</Button.Group>
</div>
</div>
</div>
</Segment> </Segment>
</div> </div>
); );

View File

@ -3,9 +3,8 @@ import styled from 'styled-components';
import { useSelector } from 'react-redux'; import { useSelector } from 'react-redux';
import utils from '../../../../utils/utils.js'; import utils from '../../../../utils/utils.js';
const StyledWarp = styled.div` const WarpContainer = styled.div`
top:0px; top:0px;
right:0px;
position: absolute; position: absolute;
right: ${props => (props.treadles * props.baseSize) + 20}px; right: ${props => (props.treadles * props.baseSize) + 20}px;
height: ${props => (props.shafts * props.baseSize) + 40}px; height: ${props => (props.shafts * props.baseSize) + 40}px;
@ -17,6 +16,24 @@ const StyledWarp = styled.div`
} }
`; `;
const WarpCanvas = styled.canvas`
position: absolute;
top: 10px;
right: 0px;
height: ${props => props.warp.shafts * props.baseSize}px;
width: ${props => props.warp.threading.length * props.baseSize}px;
border-radius: 4;
box-shadow: 0px 0px 10px rgba(0,0,0,0.15);
`;
const Colourway = styled.canvas`
position: absolute;
top: 0px;
right: 0px;
height: 10px;
width: ${props => props.warp.threading.length * props.baseSize}px;
`;
const squares = {}; const squares = {};
const markers = {}; const markers = {};
const selectedMarkers = {}; const selectedMarkers = {};
@ -147,6 +164,10 @@ function Warp({ baseSize, cellStyle, warp, weft, updatePattern }) {
if (thread >= warp.threading.length) fillUpTo(newWarp, thread); if (thread >= warp.threading.length) fillUpTo(newWarp, thread);
newWarp.threading[thread].colour = editor.colour; newWarp.threading[thread].colour = editor.colour;
} }
if (editor.tool === 'eraser') {
if (thread >= warp.threading.length) fillUpTo(newWarp, thread);
newWarp.threading[thread].shaft = 0;
}
if (editor.tool === 'straight') { if (editor.tool === 'straight') {
while (x <= hX && x >= lX) { while (x <= hX && x >= lX) {
if (x >= warp.threading.length || warp.threading.length - x < 5) fillUpTo(newWarp, x + 5); if (x >= warp.threading.length || warp.threading.length - x < 5) fillUpTo(newWarp, x + 5);
@ -183,6 +204,13 @@ function Warp({ baseSize, cellStyle, warp, weft, updatePattern }) {
warpThread.shaft = warpThread.shaft === shaft ? 0 : shaft; warpThread.shaft = warpThread.shaft === shaft ? 0 : shaft;
updatePattern({ warp: newWarp }); updatePattern({ warp: newWarp });
} }
if (editor.tool === 'eraser') {
const newWarp = Object.assign({}, warp);
if (thread > warp.threading.length || warp.threading.length - thread < 5) fillUpTo(newWarp, thread + 5);
const warpThread = newWarp.threading[thread];
warpThread.shaft = 0;
updatePattern({ warp: newWarp });
}
if (editor.tool === 'select') { if (editor.tool === 'select') {
const newWarp = Object.assign({}, warp); const newWarp = Object.assign({}, warp);
const warpThread = newWarp.threading[thread]; const warpThread = newWarp.threading[thread];
@ -306,31 +334,22 @@ function Warp({ baseSize, cellStyle, warp, weft, updatePattern }) {
}; };
return ( return (
<StyledWarp treadles={weft.treadles} shafts={warp.shafts} baseSize={baseSize} tool={tool}> <WarpContainer treadles={weft.treadles} shafts={warp.shafts} baseSize={baseSize} tool={tool}>
<canvas className='warp-colourway joyride-warpColourway' ref={colourwayRef} width={warp.threading.length * baseSize} height={10} <Colourway className='warp-colourway joyride-warpColourway' ref={colourwayRef} width={warp.threading.length * baseSize} height={10} warp={warp} baseSize={baseSize}
style={{
position: 'absolute', top: 0, right: 0, height: 10, width: warp.threading.length * baseSize,
}}
onClick={mouseClickColourway} onClick={mouseClickColourway}
onMouseDown={mouseDownColourway} onMouseDown={mouseDownColourway}
onMouseMove={mouseMoveColourway} onMouseMove={mouseMoveColourway}
onMouseUp={mouseUpColourway} onMouseUp={mouseUpColourway}
onMouseLeave={mouseUpColourway} onMouseLeave={mouseUpColourway}
/> />
<canvas className='warp-threads joyride-warp' ref={warpRef} width={warp.threading.length * baseSize} height={warp.shafts * baseSize} <WarpCanvas className='warp-threads joyride-warp' ref={warpRef} width={warp.threading.length * baseSize} height={warp.shafts * baseSize} warp={warp} baseSize={baseSize}
style={{
position: 'absolute', top: 10, right: 0,
height: warp.shafts * baseSize,
width: warp.threading.length * baseSize, borderRadius: 4,
boxShadow: '0px 0px 10px rgba(0,0,0,0.15)',
}}
onClick={click} onClick={click}
onMouseDown={mouseDown} onMouseDown={mouseDown}
onMouseMove={mouseMove} onMouseMove={mouseMove}
onMouseUp={mouseUp} onMouseUp={mouseUp}
onMouseLeave={mouseUp} onMouseLeave={mouseUp}
/> />
</StyledWarp> </WarpContainer>
); );
} }

View File

@ -3,9 +3,7 @@ import styled from 'styled-components';
import { useSelector } from 'react-redux'; import { useSelector } from 'react-redux';
import utils from '../../../../utils/utils.js'; import utils from '../../../../utils/utils.js';
const StyledWeft = styled.div` const WeftContainer = styled.div`
top:0px;
right:0px;
position: absolute; position: absolute;
top: ${props => (props.shafts * props.baseSize) + 20}px; top: ${props => (props.shafts * props.baseSize) + 20}px;
right: 0; right: 0;
@ -19,6 +17,24 @@ const StyledWeft = styled.div`
} }
`; `;
const WeftCanvas = styled.canvas`
position: absolute;
top: 0px;
right: 10px;
height: ${props => props.weft.treadling?.length * props.baseSize}px;
width: ${props => props.weft.treadles * props.baseSize}px;
border-radius: 4;
box-shadow: 0px 0px 10px rgba(0,0,0,0.15);
`;
const Colourway = styled.canvas`
position: absolute;
top: 0px;
right: 0px;
width: 10px;
height: ${props => props.weft.treadling?.length * props.baseSize}px;
`;
const squares = {}; const squares = {};
const markers = {}; const markers = {};
let dragging = false; let dragging = false;
@ -148,6 +164,10 @@ function Weft({ cellStyle, warp, weft, baseSize, updatePattern }) {
if ((thread - 1) >= weft.treadling.length) fillUpTo(newWeft, (thread - 1)); if ((thread - 1) >= weft.treadling.length) fillUpTo(newWeft, (thread - 1));
newWeft.treadling[thread - 1].colour = editor.colour; newWeft.treadling[thread - 1].colour = editor.colour;
} }
if (editor.tool === 'eraser') {
if ((thread - 1) >= weft.treadling.length) fillUpTo(newWeft, (thread - 1));
newWeft.treadling[thread - 1].treadle = 0;
}
if (editor.tool === 'straight') { if (editor.tool === 'straight') {
while (y <= hY && y >= lY) { while (y <= hY && y >= lY) {
if ((y - 1) >= weft.treadling.length || weft.treadling.length - y - 1 < 5) fillUpTo(newWeft, (y + 5)); if ((y - 1) >= weft.treadling.length || weft.treadling.length - y - 1 < 5) fillUpTo(newWeft, (y + 5));
@ -185,6 +205,14 @@ function Weft({ cellStyle, warp, weft, baseSize, updatePattern }) {
weftThread.treadle = weftThread.treadle === treadle ? 0 : treadle; weftThread.treadle = weftThread.treadle === treadle ? 0 : treadle;
updatePattern({ weft: newWeft }); updatePattern({ weft: newWeft });
} }
if (editor.tool === 'eraser') {
treadle += 1;
const newWeft = Object.assign({}, weft);
if (thread >= newWeft.treadling.length || newWeft.treadling.length - thread < 5) fillUpTo(newWeft, thread + 5);
const weftThread = newWeft.treadling[thread - 1];
weftThread.treadle = 0;
updatePattern({ weft: newWeft });
}
if (editor.tool === 'select') { if (editor.tool === 'select') {
const newWeft = Object.assign({}, weft); const newWeft = Object.assign({}, weft);
const weftThread = newWeft.treadling[thread - 1]; const weftThread = newWeft.treadling[thread - 1];
@ -309,28 +337,22 @@ function Weft({ cellStyle, warp, weft, baseSize, updatePattern }) {
const threadCount = weft.treadling?.length || 0; const threadCount = weft.treadling?.length || 0;
return ( return (
<StyledWeft baseSize={baseSize} treadles={weft.treadles} shafts={warp.shafts} threads={threadCount} tool={tool}> <WeftContainer baseSize={baseSize} treadles={weft.treadles} shafts={warp.shafts} threads={threadCount} tool={tool}>
<canvas className='weft-colourway' ref={colourwayRef} width={10} height={threadCount * baseSize} <Colourway className='weft-colourway' ref={colourwayRef} width={10} height={threadCount * baseSize} weft={weft} baseSize={baseSize}
style={{ position: 'absolute', top: 0, right: 0, width: 10, height: threadCount * baseSize}}
onClick={mouseClickColourway} onClick={mouseClickColourway}
onMouseDown={mouseDownColourway} onMouseDown={mouseDownColourway}
onMouseMove={mouseMoveColourway} onMouseMove={mouseMoveColourway}
onMouseUp={mouseUpColourway} onMouseUp={mouseUpColourway}
onMouseLeave={mouseUpColourway} onMouseLeave={mouseUpColourway}
/> />
<canvas className='weft-threads joyride-weft' ref={weftRef} width={weft.treadles * baseSize} height={threadCount * baseSize} <WeftCanvas className='weft-threads joyride-weft' ref={weftRef} width={weft.treadles * baseSize} height={threadCount * baseSize} weft={weft} baseSize={baseSize}
style={{
position: 'absolute',
top: 0, right: 10, height: threadCount * baseSize, width: weft.treadles * baseSize,
borderRadius: 4, boxShadow: '0px 0px 10px rgba(0,0,0,0.15)',
}}
onClick={click} onClick={click}
onMouseDown={mouseDown} onMouseDown={mouseDown}
onMouseMove={mouseMove} onMouseMove={mouseMove}
onMouseUp={mouseUp} onMouseUp={mouseUp}
onMouseLeave={mouseUp} onMouseLeave={mouseUp}
/> />
</StyledWeft> </WeftContainer>
); );
} }

View File

@ -1,7 +1,7 @@
import 'react-app-polyfill/ie9'; import 'react-app-polyfill/ie9';
import React from 'react'; import React from 'react';
import { createRoot } from 'react-dom/client'; import { createRoot } from 'react-dom/client';
import { BrowserRouter } from 'react-router-dom'; import { RouterProvider, createBrowserRouter } from 'react-router-dom';
import { createStore } from 'redux'; import { createStore } from 'redux';
import { Provider } from 'react-redux'; import { Provider } from 'react-redux';
import * as Sentry from '@sentry/react'; import * as Sentry from '@sentry/react';
@ -10,7 +10,48 @@ import 'react-toastify/dist/ReactToastify.css';
import 'pell/dist/pell.min.css'; import 'pell/dist/pell.min.css';
import reducers from './reducers'; import reducers from './reducers';
import App from './components/App'; import App from './components/App';
import Home from './components/main/Home';
import MarketingHome from './components/marketing/Home';
import PrivacyPolicy from './components/marketing/PrivacyPolicy';
import TermsOfUse from './components//marketing/TermsOfUse';
import Login from './components/Login';
import ForgottenPassword from './components/ForgottenPassword';
import ResetPassword from './components/ResetPassword';
import Settings from './components/main/settings/Settings';
import SettingsIdentity from './components/main/settings/Identity';
import SettingsNotification from './components/main/settings/Notification';
import SettingsAccount from './components/main/settings/Account';
import Profile from './components/main/users/Profile';
import ProfileEdit from './components/main/users/EditProfile';
import ProfileProjects from './components/main/users/ProfileProjects';
import NewProject from './components/main/projects/New';
import Project from './components/main/projects/Project';
import ProjectObjects from './components/main/projects/ProjectObjects';
import ProjectSettings from './components/main/projects/Settings';
import ObjectDraft from './components/main/projects/objects/Draft';
import ObjectList from './components/main/projects/ObjectList';
import NewGroup from './components/main/groups/New';
import Group from './components/main/groups/Group';
import GroupFeed from './components/main/groups/Feed';
import GroupMembers from './components/main/groups/Members';
import GroupProjects from './components/main/groups/Projects';
import GroupSettings from './components/main/groups/Settings';
import Explore from './components/main/explore/Explore'
import Docs from './components/docs';
import DocsHome from './components/docs/Home';
import DocsDoc from './components/docs/Doc';
import Root from './components/main/root';
export const store = createStore(reducers); export const store = createStore(reducers);
@ -22,12 +63,56 @@ if (import.meta.env.VITE_SENTRY_DSN) {
}); });
} }
const router = createBrowserRouter([
{
path: "/",
element: <App />,
children: [
{ path: "", element: <MarketingHome /> },
{ path: "root", element: <Root /> },
{ path: "explore", element: <Explore /> },
{ path: "privacy", element: <PrivacyPolicy /> },
{ path: "terms-of-use", element: <TermsOfUse /> },
{ path: "password/forgotten", element: <ForgottenPassword /> },
{ path: "password/reset", element: <ResetPassword /> },
{ path: "settings", element: <Settings />, children: [
{ path: "identity", element: <SettingsIdentity /> },
{ path: "notifications", element: <SettingsNotification /> },
{ path: "account", element: <SettingsAccount /> },
{ path: "", element: <SettingsIdentity /> },
] },
{ path: "projects/new", element: <NewProject /> },
{ path: "projects", element: <Home /> },
{ path: "groups/new", element: <NewGroup /> },
{ path: "groups/:id", element: <Group />, children: [
{ path: "feed", element: <GroupFeed /> },
{ path: "members", element: <GroupMembers /> },
{ path: "projects", element: <GroupProjects /> },
{ path: "settings", element: <GroupSettings /> },
{ path: "", element: <GroupFeed /> },
] },
{ path: "docs", element: <Docs />, children: [
{ path: ":doc", element: <DocsDoc /> },
{ path: "", element: <DocsHome /> },
] },
{ path: ":username/:projectPath", element: <Project />, children: [
{ path: "settings", element: <ProjectSettings /> },
{ path: ":objectId/edit", element: <ObjectDraft /> },
{ path: ":objectId", element: <ProjectObjects /> },
{ path: "", element: <ObjectList /> },
] },
{ path: ":username", element: <Profile />, children: [
{ path: "edit", element: <ProfileEdit /> },
{ path: "", element: <ProfileProjects /> },
] },
],
},
]);
const container = document.getElementById('root'); const container = document.getElementById('root');
const root = createRoot(container); const root = createRoot(container);
root.render( root.render(
<Provider store={store}> <Provider store={store}>
<BrowserRouter> <RouterProvider router={router} />
<App />
</BrowserRouter>
</Provider> </Provider>
); );

View File

@ -7,7 +7,7 @@ const initialState = {
explorePage: 1, explorePage: 1,
comments: [], comments: [],
selected: null, selected: null,
editor: { tool: 'straight', colour: 'orange', view: 'interlacement', autoExtend: true }, editor: { tool: 'straight', colour: null, view: 'interlacement', autoExtend: true },
}; };
function objects(state = initialState, action) { function objects(state = initialState, action) {

View File

@ -1,8 +1,6 @@
import actions from '../actions'; import actions from '../actions';
const initialState = { const initialState = {
driftReady: false,
syncedToDrift: false,
loading: false, loading: false,
errorMessage: '', errorMessage: '',
users: [], users: [],
@ -10,10 +8,6 @@ const initialState = {
function users(state = initialState, action) { function users(state = initialState, action) {
switch (action.type) { switch (action.type) {
case actions.users.INIT_DRIFT:
return Object.assign({}, state, { driftReady: true });
case actions.users.SYNC_DRIFT:
return Object.assign({}, state, { syncedToDrift: action.synced ?? true });
case actions.users.REQUEST_USERS: case actions.users.REQUEST_USERS:
return Object.assign({}, state, { loading: true, errorMessage: '' }); return Object.assign({}, state, { loading: true, errorMessage: '' });

View File

@ -1,4 +1,5 @@
import { createConfirmation } from 'react-confirm'; import { createConfirmation } from 'react-confirm';
import { toast } from 'react-toastify';
import ConfirmModal from '../components/includes/ConfirmModal'; import ConfirmModal from '../components/includes/ConfirmModal';
import api from '../api'; import api from '../api';
@ -75,6 +76,7 @@ const utils = {
return confirm({ title, confirmation: content }); return confirm({ title, confirmation: content });
}, },
rgb(s) { rgb(s) {
if (!s) return s;
if (s.match(/^[0-9]+,[0-9]+,[0-9]+$/)) return `rgb(${s})`; if (s.match(/^[0-9]+,[0-9]+,[0-9]+$/)) return `rgb(${s})`;
if (s.match(/^rgb\([0-9]+,[0-9]+[0-9]+\)$/)) return s; if (s.match(/^rgb\([0-9]+,[0-9]+[0-9]+\)$/)) return s;
if (s.match(/^#[a-zA-Z0-9]+$/)) { if (s.match(/^#[a-zA-Z0-9]+$/)) {
@ -118,7 +120,61 @@ const utils = {
}); });
} }
}); });
},
downloadDrawdownImage(object) {
const element = document.createElement('a');
element.setAttribute('target', '_blank');
element.setAttribute('href', object.previewUrl);
element.setAttribute('download', `${object.name.replace(/ /g, '_')}-drawdown.png`);
element.style.display = 'none';
document.body.appendChild(element);
element.click();
document.body.removeChild(element);
toast.info('The file has been downloaded');
},
downloadPatternImage(object) {
const element = document.createElement('a');
element.setAttribute('target', '_blank');
element.setAttribute('href', object.patternImage);
element.setAttribute('download', `${object.name.replace(/ /g, '_')}-pattern.png`);
element.style.display = 'none';
document.body.appendChild(element);
element.click();
document.body.removeChild(element);
toast.info('The file has been downloaded');
},
generateCompletePattern(pattern, parentSelector, cb) {
if (!pattern) return;
const { warp, weft } = pattern;
setTimeout(() => {
const baseSize = 6;
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
canvas.width = warp.threading?.length * baseSize + weft.treadles * baseSize + 20;
canvas.height = warp.shafts * baseSize + weft.treadling?.length * baseSize + 20;
ctx.fillStyle = 'white';
ctx.fillRect(0, 0, canvas.width, canvas.height);
ctx.fillStyle = 'black';
const warpCanvas = document.querySelector(`${parentSelector} .warp-threads`);
const warpColourwayCanvas = document.querySelector(`${parentSelector} .warp-colourway`);
const weftCanvas = document.querySelector(`${parentSelector} .weft-threads`);
const weftColourwayCanvas = document.querySelector(`${parentSelector} .weft-colourway`);
const drawdownCanvas = document.querySelector(`${parentSelector} .drawdown`);
const tieupsCanvas = document.querySelector(`${parentSelector} .tieups`);
if (warpCanvas) {
ctx.drawImage(warpColourwayCanvas, canvas.width - warpCanvas.width - weft.treadles * baseSize - 20, 0);
ctx.drawImage(warpCanvas, canvas.width - warpCanvas.width - weft.treadles * baseSize - 20, 10);
ctx.drawImage(weftCanvas, canvas.width - 10 - weft.treadles * baseSize, warp.shafts * baseSize + 20);
ctx.drawImage(weftColourwayCanvas, canvas.width - 10, warp.shafts * baseSize + 20);
ctx.drawImage(tieupsCanvas, canvas.width - weft.treadles * baseSize - 10, 10);
ctx.drawImage(drawdownCanvas, canvas.width - drawdownCanvas.width - weft.treadles * baseSize - 20, warp.shafts * baseSize + 20);
setTimeout(() => {
const im = canvas.toDataURL('image/png')
if (im?.length > 20) cb(im);
}, 500);
} }
}, 500);
},
}; };
export default utils; export default utils;

View File

@ -424,7 +424,7 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"@babel/runtime@npm:^7.12.1, @babel/runtime@npm:^7.7.6": "@babel/runtime@npm:^7.12.1":
version: 7.17.9 version: 7.17.9
resolution: "@babel/runtime@npm:7.17.9" resolution: "@babel/runtime@npm:7.17.9"
dependencies: dependencies:
@ -691,6 +691,13 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"@remix-run/router@npm:1.14.1":
version: 1.14.1
resolution: "@remix-run/router@npm:1.14.1"
checksum: a3a0e7bd1917a4fd10322269b78ce1c5917a74889dfa747305b5a56e5d441b9187e1d8aaa6d58c4bf77713e7baca825f87057a64b2d21718cdb361f1cda75687
languageName: node
linkType: hard
"@rollup/pluginutils@npm:^4.2.1": "@rollup/pluginutils@npm:^4.2.1":
version: 4.2.1 version: 4.2.1
resolution: "@rollup/pluginutils@npm:4.2.1" resolution: "@rollup/pluginutils@npm:4.2.1"
@ -1984,15 +1991,6 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"history@npm:^5.2.0":
version: 5.3.0
resolution: "history@npm:5.3.0"
dependencies:
"@babel/runtime": ^7.7.6
checksum: d73c35df49d19ac172f9547d30a21a26793e83f16a78386d99583b5bf1429cc980799fcf1827eb215d31816a6600684fba9686ce78104e23bd89ec239e7c726f
languageName: node
linkType: hard
"hoist-non-react-statics@npm:^3.0.0, hoist-non-react-statics@npm:^3.3.0, hoist-non-react-statics@npm:^3.3.2": "hoist-non-react-statics@npm:^3.0.0, hoist-non-react-statics@npm:^3.3.0, hoist-non-react-statics@npm:^3.3.2":
version: 3.3.2 version: 3.3.2
resolution: "hoist-non-react-statics@npm:3.3.2" resolution: "hoist-non-react-statics@npm:3.3.2"
@ -3057,27 +3055,27 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"react-router-dom@npm:^6.3.0": "react-router-dom@npm:^6.21.1":
version: 6.3.0 version: 6.21.1
resolution: "react-router-dom@npm:6.3.0" resolution: "react-router-dom@npm:6.21.1"
dependencies: dependencies:
history: ^5.2.0 "@remix-run/router": 1.14.1
react-router: 6.3.0 react-router: 6.21.1
peerDependencies: peerDependencies:
react: ">=16.8" react: ">=16.8"
react-dom: ">=16.8" react-dom: ">=16.8"
checksum: 77603a654f8a8dc7f65535a2e5917a65f8d9ffcb06546d28dd297e52adcc4b8a84377e0115f48dca330b080af2da3e78f29d590c89307094d36927d2b1751ec3 checksum: d8ea3370babab626827008ced8a776ac991023745dc69d44332b6a5229dd2a6899f3e46fc50365cef0c7487cf726bcdccb365ec106915f92c735da6e4c2e6b97
languageName: node languageName: node
linkType: hard linkType: hard
"react-router@npm:6.3.0": "react-router@npm:6.21.1":
version: 6.3.0 version: 6.21.1
resolution: "react-router@npm:6.3.0" resolution: "react-router@npm:6.21.1"
dependencies: dependencies:
history: ^5.2.0 "@remix-run/router": 1.14.1
peerDependencies: peerDependencies:
react: ">=16.8" react: ">=16.8"
checksum: 7be673f5e72104be01e6ab274516bdb932efd93305243170690f6560e3bd1035dd1df3d3c9ce1e0f452638a2529f43a1e77dcf0934fc8031c4783da657be13ca checksum: c6774cf4440b524401cecb40f4fbf4dbc89d61405f9f6d3cb3d2338e262a5254b9e27bc6039dc32e2c5f8b3009e83e9d6b30a61159dd6c423297cb0e3bd46fb3
languageName: node languageName: node
linkType: hard linkType: hard
@ -3579,7 +3577,7 @@ __metadata:
react-joyride: ^2.4.0 react-joyride: ^2.4.0
react-redux: ^8.0.1 react-redux: ^8.0.1
react-rewards: ^2.0.3 react-rewards: ^2.0.3
react-router-dom: ^6.3.0 react-router-dom: ^6.21.1
react-toastify: ^4.4.0 react-toastify: ^4.4.0
redux: ^4.0.0 redux: ^4.0.0
sanitize-html: ^1.19.1 sanitize-html: ^1.19.1