Compare commits

...

5 Commits

Author SHA1 Message Date
9920a0a596 full snippet support
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2024-03-06 13:36:05 +00:00
ec32489de5 chooser basics for snippets 2024-03-04 21:17:30 +00:00
78a197d5e4 allow saving snippets 2024-03-04 20:29:27 +00:00
fad3bc835b more UI for snippets 2024-03-03 21:39:30 +01:00
9d3ed248b3 base code for snippet handling 2024-03-03 21:29:18 +01:00
11 changed files with 358 additions and 41 deletions

36
api/api/snippets.py Normal file
View File

@ -0,0 +1,36 @@
import datetime
from bson.objectid import ObjectId
from util import database, util
def list_for_user(user):
db = database.get_db()
snippets = db.snippets.find({'user': user['_id']}).sort('createdAt', -1)
return {'snippets': list(snippets)}
def create(user, data):
if not data: raise util.errors.BadRequest('Invalid request')
name = data.get('name', '')
snippet_type = data.get('type', '')
if len(name) < 3: raise util.errors.BadRequest('A longer name is required')
if snippet_type not in ['warp', 'weft']:
raise util.errors.BadRequest('Invalid snippet type')
db = database.get_db()
snippet = {
'name': name,
'user': user['_id'],
'createdAt': datetime.datetime.utcnow(),
'type': snippet_type,
'threading': data.get('threading', []),
'treadling': data.get('treadling', []),
}
result = db.snippets.insert_one(snippet)
snippet['_id'] = result.inserted_id
return snippet
def delete(user, id):
db = database.get_db()
snippet = db.snippets.find_one({'_id': ObjectId(id), 'user': user['_id']})
if not snippet:
raise util.errors.NotFound('Snippet not found')
db.snippets.remove({'_id': snippet['_id']})
return {'deletedSnippet': snippet['_id'] }

View File

@ -6,7 +6,7 @@ import werkzeug
import sentry_sdk import sentry_sdk
from sentry_sdk.integrations.flask import FlaskIntegration from sentry_sdk.integrations.flask import FlaskIntegration
from util import util from util import util
from api import accounts, users, projects, objects, uploads, groups, search, invitations, root, activitypub from api import accounts, users, projects, objects, snippets, uploads, groups, search, invitations, root, activitypub
app = Flask(__name__) app = Flask(__name__)
CORS(app) CORS(app)
@ -188,6 +188,19 @@ def object_comments(id):
def object_comment(id, comment_id): def object_comment(id, comment_id):
return util.jsonify(objects.delete_comment(util.get_user(), id, comment_id)) return util.jsonify(objects.delete_comment(util.get_user(), id, comment_id))
# SNIPPETS
@app.route('/snippets', methods=['POST', 'GET'])
def snippets_route():
if request.method == 'POST':
return util.jsonify(snippets.create(util.get_user(), request.json))
if request.method == 'GET':
return util.jsonify(snippets.list_for_user(util.get_user()))
@app.route('/snippets/<id>', methods=['DELETE'])
def snippet_route(id):
if request.method == 'DELETE':
return util.jsonify(snippets.delete(util.get_user(), id))
# GROUPS # GROUPS
@app.route('/groups', methods=['POST', 'GET']) @app.route('/groups', methods=['POST', 'GET'])

View File

@ -8,6 +8,8 @@ export default {
DELETE_OBJECT: 'DELETE_OBJECT', DELETE_OBJECT: 'DELETE_OBJECT',
SELECT_OBJECT: 'SELECT_OBJECT', SELECT_OBJECT: 'SELECT_OBJECT',
UPDATE_EDITOR: 'UPDATE_EDITOR', UPDATE_EDITOR: 'UPDATE_EDITOR',
RECEIVE_SNIPPET: 'RECEIVE_SNIPPET',
DELETE_SNIPPET: 'DELETE_SNIPPET',
RECEIVE_COMMENT: 'RECEIVE_COMMENT', RECEIVE_COMMENT: 'RECEIVE_COMMENT',
DELETE_COMMENT: 'DELETE_COMMENT', DELETE_COMMENT: 'DELETE_COMMENT',
@ -45,6 +47,14 @@ export default {
return { type: this.UPDATE_EDITOR, editor }; return { type: this.UPDATE_EDITOR, editor };
}, },
receiveSnippet(snippet) {
return { type: this.RECEIVE_SNIPPET, snippet };
},
deleteSnippet(snippetId) {
return { type: this.DELETE_SNIPPET, snippetId };
},
receiveComment(comment) { receiveComment(comment) {
return { type: this.RECEIVE_COMMENT, comment }; return { type: this.RECEIVE_COMMENT, comment };
}, },

View File

@ -5,6 +5,7 @@ import { accounts } from './accounts';
import { users } from './users'; import { users } from './users';
import { projects } from './projects'; import { projects } from './projects';
import { objects } from './objects'; import { objects } from './objects';
import { snippets } from './snippets';
import { uploads } from './uploads'; import { uploads } from './uploads';
import { groups } from './groups'; import { groups } from './groups';
import { search } from './search'; import { search } from './search';
@ -53,7 +54,7 @@ export const api = {
api.req(method, path, data, success, fail, true, options); api.req(method, path, data, success, fail, true, options);
}, },
auth, accounts, users, projects, objects, uploads, groups, search, invitations, root auth, accounts, users, projects, objects, snippets, uploads, groups, search, invitations, root
}; };
export default api; export default api;

13
web/src/api/snippets.js Normal file
View File

@ -0,0 +1,13 @@
import api from '.';
export const snippets = {
create(data, success, fail) {
api.authenticatedRequest('POST', '/snippets', data, success, fail);
},
listForUser(success, fail) {
api.authenticatedRequest('GET', `/snippets`, null, success, fail);
},
delete(snippetId, success, fail) {
api.authenticatedRequest('DELETE', `/snippets/${snippetId}`, null, success, fail);
},
};

View File

@ -0,0 +1,142 @@
import React, { useState, useEffect } from 'react';
import { Icon, Divider, Header, Button, Modal, ModalHeader, ModalContent, ModalDescription, ModalActions, Input, Card, Checkbox } from 'semantic-ui-react';
import styled from 'styled-components';
import { useDispatch, useSelector } from 'react-redux';
import api from '../../api';
import actions from '../../actions';
import Warp from '../main/projects/objects/Warp';
import Weft from '../main/projects/objects/Weft';
import { PreviewContainer } from './SnippetSaver';
export default function SnippetChooser({ type, isOpen, onComplete }) {
const [count, setCount] = useState(1);
const [includeColours, setIncludeColours] = useState(false);
const [selectedSnippet, setSelectedSnippet] = useState(null);
const dispatch = useDispatch();
const { snippets } = useSelector(state => {
return { snippets: state.objects.snippets };
});
useEffect(() => {
api.snippets.listForUser(data => {
data?.snippets?.forEach(s => dispatch(actions.objects.receiveSnippet(s)));
});
}, []);
const deleteSnippet = (id) => {
if (!window.confirm('Are you sure you want to delete this snippet?')) return;
api.snippets.delete(id, () => {
dispatch(actions.objects.deleteSnippet(id));
setSelectedSnippet(null);
});
}
const insertSnippet = () => {
if (!count) return onComplete();
const snippet = selectedSnippet ?
{ ...snippets.find(s => s._id === selectedSnippet) }
:
{
type,
threading: type === 'warp' ? [{shaft: 0}] : null,
treadling: type === 'weft' ? [{treadle: 0}] : null,
};
if (snippet) {
const key = type === 'warp' ? 'threading' : 'treadling';
const baseArray = [...snippet[key]];
const newArray = [];
for (let i = 0; i < count; i++) {
baseArray.forEach(t => {
newArray.push({ ...t, colour: includeColours ? t.colour : undefined });
});
}
snippet[key] = newArray;
}
onComplete(snippet);
}
const typeSnippets = snippets.filter(s => s.type === type);
return (
<Modal
onClose={() => onComplete()}
open={isOpen}
>
<ModalHeader>Insert into the {type}</ModalHeader>
<ModalContent>
<ModalDescription>
<Input
label={{ basic: true, content: 'Number of repeats to insert:' }}
labelPosition='left'
placeholder='Count'
value={count}
onChange={(e, { value }) => setCount(parseInt(value || 0) || 0)}
/>
<Divider hidden />
<Header size='small'>Choose a blank thread or a snippet</Header>
<Card.Group stackable doubling itemsPerRow={4}>
<Card
raised={!selectedSnippet}
color={!selectedSnippet ? 'blue' : null}
onClick={() => setSelectedSnippet(null)}
>
<Card.Content textAlign='center'>
{!selectedSnippet && <Icon name='check circle' size='sm' />}
<Header size='sm'>Blank thread</Header>
</Card.Content>
</Card>
{!typeSnippets.length ? <Card>
<Card.Content textAlign='center'>
You don't have any {type} snippets yet. Create one by choosing threads in your pattern using the "select" tool.
</Card.Content>
</Card> : null}
{typeSnippets.map(s => {
const shafts = type === 'warp' ? Math.max(...s.threading?.map(t => t.shaft)) : 0;
const treadles = type === 'weft' ? Math.max(...s.treadling?.map(t => t.treadle)) : 0;
return (<Card key={s._id}
raised={selectedSnippet === s._id}
color={selectedSnippet === s._id ? 'blue' : null}
onClick={() => setSelectedSnippet(s._id)}
>
<Card.Content textAlign='center'>
{selectedSnippet === s._id && <Icon name='check circle' size='sm' />}
<Header size='sm' style={{margin: 0}}>
{s.name}
</Header>
<div style={{overflow: 'scroll'}}>
<PreviewContainer type={type} shafts={shafts} treadles={treadles} threading={s.threading} treadling={s.treadling}>
{type === 'warp' &&
<Warp baseSize={10} warp={{threading: s.threading, shafts}} weft={{treadles, treadling: []}}/>
}
{type === 'weft' &&
<Weft baseSize={10} warp={{shafts, threading: []}} weft={{treadling: s.treadling, treadles}}/>
}
</PreviewContainer>
</div>
</Card.Content>
<Card.Content extra>
<a href='#' onClick={() => deleteSnippet(s._id)}>Delete snippet</a>
</Card.Content>
</Card>);
})}
</Card.Group>
{selectedSnippet && <>
<Divider hidden />
<Checkbox
label='Insert with snippet colours'
onChange={(e, data) => setIncludeColours(data.checked)}
checked={includeColours}
/>
</>}
</ModalDescription>
</ModalContent>
<ModalActions>
<Button color='black' onClick={() => onComplete()}>Cancel</Button>
<Button onClick={insertSnippet} positive>Insert selected thread or snippet</Button>
</ModalActions>
</Modal>
);
}

View File

@ -0,0 +1,78 @@
import React, { useState, useEffect } from 'react';
import { Button, Card, Header, Modal, ModalHeader, ModalContent, ModalDescription, ModalActions, Input } from 'semantic-ui-react';
import styled from 'styled-components';
import api from '../../api';
import Warp from '../main/projects/objects/Warp';
import Weft from '../main/projects/objects/Weft';
export const PreviewContainer = styled.div`
display: inline-block;
margin-top: 10px;
position: relative;
height: ${p =>
(p.type === 'warp' ? p.shafts * 10 :
p.type === 'weft' ? p.treadling?.length * 10 : 0) + 20}px;
}px;
width: ${p =>
(p.type === 'warp' ? p.threading?.length * 10 :
p.type === 'weft' ? p.treadles * 10 : 0) + 20}px;
}px;
overflow-y: scroll;
`;
export default function SnippetSaver({ type, threading, treadling, isOpen, onComplete }) {
const [name, setName] = useState('');
useEffect(() => {
setName('Snippet made on ' + new Date().toLocaleString());
}, []);
const save = () => {
api.snippets.create({ name, type, threading, treadling }, newSnippet => {
onComplete(newSnippet);
});
}
let shafts = 0;
threading?.forEach(thread => {
if (thread.shaft > shafts) shafts = thread.shaft;
});
let treadles = 0;
treadling?.forEach(thread => {
if (thread.treadle > treadles) treadles = thread.treadle;
});
return (
<Modal
onClose={() => onComplete()}
open={isOpen}
>
<ModalHeader>Save a {type} snippet</ModalHeader>
<ModalContent>
<ModalDescription>
<p>Snippets are partial warps or wefts that you can re-use later in this and other drafts.</p>
<p><strong>Give your snippet a name:</strong></p>
<Input placeholder='Type a name...' value={name} onChange={e => setName(e.target.value)} fluid autoFocus />
<Card raised>
<Card.Content textAlign='center'>
<Header size='sm'>Preview</Header>
<div style={{overflow: 'scroll'}}>
<PreviewContainer type={type} threading={threading} treadling={treadling} shafts={shafts} treadles={treadles}>
{type === 'warp' &&
<Warp baseSize={10} warp={{threading, shafts}} weft={{treadles: 0, treadling: []}}/>
}
{type === 'weft' &&
<Weft baseSize={10} warp={{shafts: 0, threading: []}} weft={{treadling, treadles}}/>
}
</PreviewContainer>
</div>
</Card.Content>
</Card>
</ModalDescription>
</ModalContent>
<ModalActions>
<Button color='black' onClick={() => onComplete()}>Cancel</Button>
<Button onClick={save} positive>Save snippet</Button>
</ModalActions>
</Modal>
);
}

View File

@ -10,6 +10,8 @@ import Slider from 'rc-slider';
import styled from 'styled-components'; import styled from 'styled-components';
import HelpLink from '../../../includes/HelpLink'; import HelpLink from '../../../includes/HelpLink';
import Tour, { ReRunTour } from '../../../includes/Tour'; import Tour, { ReRunTour } from '../../../includes/Tour';
import SnippetSaver from '../../../includes/SnippetSaver';
import SnippetChooser from '../../../includes/SnippetChooser';
import 'rc-slider/assets/index.css'; import 'rc-slider/assets/index.css';
@ -55,6 +57,7 @@ function Tools({ object, pattern, warp, weft, unsaved, saving, baseSize, updateP
const [deleteModalOpen, setDeleteModalOpen] = useState(false); const [deleteModalOpen, setDeleteModalOpen] = useState(false);
const [newColour, setNewColour] = useState('#22194D'); const [newColour, setNewColour] = useState('#22194D');
const [selectedThreadCount, setSelectedThreadCount] = useState(0); const [selectedThreadCount, setSelectedThreadCount] = useState(0);
const [creatingSnippet, setCreatingSnippet] = useState(null);
const navigate = useNavigate(); const navigate = useNavigate();
const dispatch = useDispatch(); const dispatch = useDispatch();
const { objectId, username, projectPath } = useParams(); const { objectId, username, projectPath } = useParams();
@ -134,6 +137,33 @@ function Tools({ object, pattern, warp, weft, unsaved, saving, baseSize, updateP
newWeft.treadling = newWeft?.treadling?.map(t => ({ ...t, isSelected: undefined })); newWeft.treadling = newWeft?.treadling?.map(t => ({ ...t, isSelected: undefined }));
updatePattern({ warp: newWarp, weft: newWeft }); updatePattern({ warp: newWarp, weft: newWeft });
} }
const saveSnippet = () => {
const selectedWarp = warp?.threading?.filter(t => t.isSelected)?.map(t => ({ shaft: t.shaft, colour: t.colour }));
const selectedWeft = weft?.treadling?.filter(t => t.isSelected)?.map(t => ({ treadle: t.treadle, colour: t.colour}));
if (selectedWarp?.length) setCreatingSnippet({ type: 'warp', threading: selectedWarp, treadling: null });
else setCreatingSnippet({ type: 'weft', threading: null, treadling: selectedWeft });
}
const onSaveSnippet = (snippet) => {
setCreatingSnippet(null);
dispatch(actions.objects.receiveSnippet(snippet));
if (snippet) {
toast('📌 Snippet saved');
}
}
const onInsertSnippet = (snippet) => {
if (snippet) {
const newWarp = Object.assign({}, pattern.warp);
const newWeft = Object.assign({}, pattern.weft);
if (snippet.type === 'warp') {
newWarp.threading.splice(editor.insertPoint, 0, ...snippet.threading);
}
else {
newWeft.treadling.splice(editor.insertPoint, 0, ...snippet.treadling);
}
updatePattern({ warp: newWarp, weft: newWeft });
}
dispatch(actions.objects.updateEditor( { insertType: null, inseertPoint: null }));
}
const onZoomChange = zoom => updatePattern({ baseSize: zoom || 10 }); const onZoomChange = zoom => updatePattern({ baseSize: zoom || 10 });
@ -208,6 +238,7 @@ function Tools({ object, pattern, warp, weft, unsaved, saving, baseSize, updateP
<div> <div>
<Header>{selectedThreadCount} threads selected</Header> <Header>{selectedThreadCount} threads selected</Header>
<Button size='small' basic onClick={deselectThreads}>De-select all</Button> <Button size='small' basic onClick={deselectThreads}>De-select all</Button>
<Button size='small' color='teal' onClick={saveSnippet}>Save selection as snippet</Button>
<Button size='small' color='orange' onClick={deleteSelectedThreads}>Delete threads</Button> <Button size='small' color='orange' onClick={deleteSelectedThreads}>Delete threads</Button>
</div> </div>
: :
@ -239,7 +270,7 @@ function Tools({ object, pattern, warp, weft, unsaved, saving, baseSize, updateP
<Button.Group size="tiny"> <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 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="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 data-tooltip="Insert threads or snippets" 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-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 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-straight' data-tooltip="Straight draw" color={editor.tool === 'straight' && 'blue'} size="tiny" icon onClick={() => enableTool('straight')}>/ /</Button>
@ -254,7 +285,7 @@ function Tools({ object, pattern, warp, weft, unsaved, saving, baseSize, updateP
</div> </div>
<Button.Group size="tiny"> <Button.Group size="tiny">
<Popup hoverable on="click" <Popup hoverable on="click"
trigger={<Button style={{marginLeft: 5}} size="tiny" icon="tint" style={{color: utils.rgb(editor.colour)}} />} trigger={<Button size="tiny" icon="tint" style={{marginLeft: 5, color: utils.rgb(editor.colour)}} />}
content={<div style={{width: 150}}> content={<div style={{width: 150}}>
{pattern.colours && pattern.colours.map(colour => {pattern.colours && pattern.colours.map(colour =>
<ColourSquare key={colour} colour={utils.rgb(colour)} onClick={() => setColour(colour)} /> <ColourSquare key={colour} colour={utils.rgb(colour)} onClick={() => setColour(colour)} />
@ -332,26 +363,6 @@ function Tools({ object, pattern, warp, weft, unsaved, saving, baseSize, updateP
onConfirm={deleteObject} onConfirm={deleteObject}
/> />
{/*}
<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' <Dropdown style={{marginLeft: 5}} icon={null} direction='left'
trigger={<Button basic size='tiny' icon='help' />} trigger={<Button basic size='tiny' icon='help' />}
> >
@ -370,6 +381,10 @@ function Tools({ object, pattern, warp, weft, unsaved, saving, baseSize, updateP
</div> </div>
</div> </div>
</Segment> </Segment>
{creatingSnippet &&
<SnippetSaver type={creatingSnippet.type} threading={creatingSnippet.threading} treadling={creatingSnippet.treadling} isOpen={!!creatingSnippet} onComplete={onSaveSnippet} />
}
<SnippetChooser type={editor?.insertType} isOpen={!!editor?.insertType} onComplete={onInsertSnippet} />
</div> </div>
); );
} }

View File

@ -1,7 +1,8 @@
import React, { useEffect, useState, useRef } from 'react'; import React, { useEffect, useState, useRef } from 'react';
import styled from 'styled-components'; import styled from 'styled-components';
import { useSelector } from 'react-redux'; import { useDispatch, useSelector } from 'react-redux';
import utils from '../../../../utils/utils.js'; import utils from '../../../../utils/utils.js';
import actions from '../../../../actions/index.js';
const WarpContainer = styled.div` const WarpContainer = styled.div`
top:0px; top:0px;
@ -50,6 +51,7 @@ function Warp({ baseSize, cellStyle, warp, weft, updatePattern }) {
const { tool, colour } = editor; const { tool, colour } = editor;
const warpRef = useRef(null); const warpRef = useRef(null);
const colourwayRef = useRef(null); const colourwayRef = useRef(null);
const dispatch = useDispatch();
useEffect(() => paintDrawdown()); useEffect(() => paintDrawdown());
useEffect(() => { useEffect(() => {
@ -218,13 +220,7 @@ function Warp({ baseSize, cellStyle, warp, weft, updatePattern }) {
updatePattern({ warp: newWarp }); updatePattern({ warp: newWarp });
} }
if (editor.tool === 'insert') { if (editor.tool === 'insert') {
const number = parseInt(prompt('Enter a number of threads to insert before this point.')); dispatch(actions.objects.updateEditor({ insertType: 'warp', insertPoint: thread }));
if (number && number > 0) {
const newThreads = [...new Array(number)].map(() => ({ shaft: 0 }));
const newWarp = Object.assign({}, warp);
newWarp.threading?.splice(thread, 0, ...newThreads);
updatePattern({ warp: newWarp });
}
} }
}; };

View File

@ -1,7 +1,8 @@
import React, { useEffect, useState, useRef } from 'react'; import React, { useEffect, useState, useRef } from 'react';
import styled from 'styled-components'; import styled from 'styled-components';
import { useSelector } from 'react-redux'; import { useDispatch, useSelector } from 'react-redux';
import utils from '../../../../utils/utils.js'; import utils from '../../../../utils/utils.js';
import actions from '../../../../actions';
const WeftContainer = styled.div` const WeftContainer = styled.div`
position: absolute; position: absolute;
@ -50,6 +51,7 @@ function Weft({ cellStyle, warp, weft, baseSize, updatePattern }) {
const { tool, colour } = editor; const { tool, colour } = editor;
const weftRef = useRef(null); const weftRef = useRef(null);
const colourwayRef = useRef(null); const colourwayRef = useRef(null);
const dispatch = useDispatch();
useEffect(() => paintDrawdown()); useEffect(() => paintDrawdown());
useEffect(() => { useEffect(() => {
@ -220,13 +222,7 @@ function Weft({ cellStyle, warp, weft, baseSize, updatePattern }) {
updatePattern({ weft: newWeft }); updatePattern({ weft: newWeft });
} }
if (editor.tool === 'insert') { if (editor.tool === 'insert') {
const number = parseInt(prompt('Enter a number of threads to insert above this point.')); dispatch(actions.objects.updateEditor({ insertType: 'weft', insertPoint: thread - 1 }));
if (number && number > 0) {
const newThreads = [...new Array(number)].map(() => ({ treadle: 0 }));
const newWeft = Object.assign({}, weft);
newWeft.treadling?.splice(thread - 1, 0, ...newThreads);
updatePattern({ weft: newWeft });
}
} }
}; };

View File

@ -8,6 +8,7 @@ const initialState = {
comments: [], comments: [],
selected: null, selected: null,
editor: { tool: 'straight', colour: null, view: 'interlacement', autoExtend: true }, editor: { tool: 'straight', colour: null, view: 'interlacement', autoExtend: true },
snippets: [],
}; };
function objects(state = initialState, action) { function objects(state = initialState, action) {
@ -76,6 +77,22 @@ function objects(state = initialState, action) {
case actions.objects.UPDATE_EDITOR: case actions.objects.UPDATE_EDITOR:
const editor = Object.assign({}, state.editor, action.editor); const editor = Object.assign({}, state.editor, action.editor);
return Object.assign({}, state, { editor }); return Object.assign({}, state, { editor });
case actions.objects.RECEIVE_SNIPPET: {
const snippets = [];
let found = false;
state.snippets.forEach((snippet) => {
if (snippet._id === action.snippet._id) {
snippet = Object.assign({}, action.snippet);
found = true;
}
snippets.push(snippet);
});
if (!found) snippets.splice(0, 0, action.snippet);
return Object.assign({}, state, { loading: false, snippets });
}
case actions.objects.DELETE_SNIPPET: {
return Object.assign({}, state, { snippets: state.snippets.filter(e => e._id !== action.snippetId) });
}
case actions.objects.RECEIVE_COMMENT: { case actions.objects.RECEIVE_COMMENT: {
let found = false; let found = false;