diff --git a/package.json b/package.json index f89f8c35f4..7667fc455b 100644 --- a/package.json +++ b/package.json @@ -31,6 +31,9 @@ "@blueprintjs/datetime2": "^2.3.3", "@blueprintjs/icons": "^5.9.0", "@blueprintjs/select": "^5.1.3", + "@dnd-kit/core": "^6.3.1", + "@dnd-kit/sortable": "^10.0.0", + "@dnd-kit/utilities": "^3.2.2", "@mantine/hooks": "^7.11.2", "@octokit/rest": "^20.0.0", "@reduxjs/toolkit": "^1.9.7", diff --git a/src/commons/sagas/StoriesSaga.ts b/src/commons/sagas/StoriesSaga.ts index c071186724..a03a345b35 100644 --- a/src/commons/sagas/StoriesSaga.ts +++ b/src/commons/sagas/StoriesSaga.ts @@ -2,6 +2,9 @@ import { Context } from 'js-slang'; import { call, put, select } from 'redux-saga/effects'; import StoriesActions from 'src/features/stories/StoriesActions'; import { + defaultContent, + // updateHeader, + defaultHeader, deleteStory, deleteUserUserGroups, getAdminPanelStoriesUsers, @@ -21,7 +24,7 @@ import { combineSagaHandlers } from '../redux/utils'; import { resetSideContent } from '../sideContent/SideContentActions'; import { actions } from '../utils/ActionsHelper'; import { showSuccessMessage, showWarningMessage } from '../utils/notifications/NotificationsHelper'; -import { defaultStoryContent } from '../utils/StoriesHelper'; +// import { defaultHeader, defaultContent } from '../utils/StoriesHelper'; import { selectTokens } from './BackendSaga'; import { evalCodeSaga } from './WorkspaceSaga/helpers/evalCode'; @@ -47,7 +50,8 @@ const StoriesSaga = combineSagaHandlers(sagaActions, { } else { const defaultStory: StoryData = { title: '', - content: defaultStoryContent, + header: defaultHeader, + content: defaultContent, pinOrder: null }; yield put(actions.setCurrentStory(defaultStory)); @@ -68,6 +72,7 @@ const StoriesSaga = combineSagaHandlers(sagaActions, { tokens, userId, story.title, + story.header, story.content, story.pinOrder ); @@ -82,11 +87,14 @@ const StoriesSaga = combineSagaHandlers(sagaActions, { saveStory: function* (action) { const tokens: Tokens = yield selectTokens(); const { story, id } = action.payload; + console.log('In saveStory'); + console.log(story.header); const updatedStory: StoryView | null = yield call( updateStory, tokens, id, story.title, + story.header, story.content, story.pinOrder ); @@ -107,6 +115,11 @@ const StoriesSaga = combineSagaHandlers(sagaActions, { yield put(actions.getStoriesList()); }, + // updateHeader: function* (action) { + // const newHeader = action.payload; + // yield call(updateHeader, newHeader); + // }, + getStoriesUser: function* () { const tokens: Tokens = yield selectTokens(); const me: { diff --git a/src/commons/sideContent/SideContent.tsx b/src/commons/sideContent/SideContent.tsx index 75cffad579..226beffe30 100644 --- a/src/commons/sideContent/SideContent.tsx +++ b/src/commons/sideContent/SideContent.tsx @@ -70,7 +70,7 @@ const SideContent = ({ renderActiveTabPanelOnly, editorWidth, ...props }: SideCo {({ tabs: allTabs, alerts: tabAlerts, changeTabsCallback, selectedTab, height }) => (
- +
ReturnType; }; +let currentIndex: number; +let view: boolean; const handleCustomComponents: HandlerType = { code: (state, node) => { const rawLang = node.lang ?? ''; @@ -167,7 +169,9 @@ const handleCustomComponents: HandlerType = { // const lang = rawLang.substring(1, rawLang.length - 1); const props: SourceBlockProps = { content: node.value, - commands: node.meta ?? '' + commands: node.meta ?? '', + index: currentIndex, + isViewOnly: view }; // Disable typecheck as "source-block" is not a standard HTML tag const element = h('source-block', props) as any; @@ -175,7 +179,13 @@ const handleCustomComponents: HandlerType = { } }; -export const renderStoryMarkdown = (markdown: string): React.ReactNode => { +export const renderStoryMarkdown = ( + markdown: string, + index: number, + isViewOnly: boolean +): React.ReactNode => { + currentIndex = index; + view = isViewOnly; const mdast = fromMarkdown(markdown); const hast = toHast(mdast, { handlers: handleCustomComponents }) ?? h(); return ( diff --git a/src/features/stories/DragContext.ts b/src/features/stories/DragContext.ts new file mode 100644 index 0000000000..6ea99a3da6 --- /dev/null +++ b/src/features/stories/DragContext.ts @@ -0,0 +1,18 @@ +import { createContext, useContext } from 'react'; + +type DragContextProps = { + index: number | null; + setIndex: (index: number) => void; +}; + +export const DragContext = createContext(null); + +export const useDragItem = () => { + const dragItem = useContext(DragContext); + + if (dragItem == null) { + throw Error('Drag Context cannot be null when in use'); + } + + return dragItem; +}; diff --git a/src/features/stories/StoriesActions.ts b/src/features/stories/StoriesActions.ts index ae90e48a81..3cf55e5753 100644 --- a/src/features/stories/StoriesActions.ts +++ b/src/features/stories/StoriesActions.ts @@ -28,6 +28,7 @@ const StoriesActions = createActions('stories', { createStory: (story: StoryParams) => story, saveStory: (story: StoryParams, id: number) => ({ story, id }), deleteStory: (id: number) => id, + updateHeader: (newHeader: string) => newHeader, // Auth-related actions getStoriesUser: () => ({}), diff --git a/src/features/stories/StoriesTypes.ts b/src/features/stories/StoriesTypes.ts index 3f524a6472..f4896c5d91 100644 --- a/src/features/stories/StoriesTypes.ts +++ b/src/features/stories/StoriesTypes.ts @@ -3,6 +3,14 @@ import { DebuggerContext } from 'src/commons/workspace/WorkspaceTypes'; import { InterpreterOutput, StoriesRole } from '../../commons/application/ApplicationTypes'; +export type StoryCell = { + // id: number; + index: number; + isCode: boolean; + env: string; + content: string; +}; + export type StoryMetadata = { authorId: number; authorName: string; @@ -10,7 +18,8 @@ export type StoryMetadata = { export type StoryData = { title: string; - content: string; + header: string; + content: StoryCell[]; pinOrder: number | null; }; diff --git a/src/features/stories/storiesComponents/BackendAccess.ts b/src/features/stories/storiesComponents/BackendAccess.ts index c494c13ed6..e590d072b4 100644 --- a/src/features/stories/storiesComponents/BackendAccess.ts +++ b/src/features/stories/storiesComponents/BackendAccess.ts @@ -6,12 +6,88 @@ import { showWarningMessage } from 'src/commons/utils/notifications/NotificationsHelper'; import { request } from 'src/commons/utils/RequestHelper'; +import { defaultStoryContent } from 'src/commons/utils/StoriesHelper'; import { RemoveLast } from 'src/commons/utils/TypeHelper'; import { store } from 'src/pages/createStore'; import { Tokens } from '../../../commons/application/types/SessionTypes'; import { NameUsernameRole } from '../../../pages/academy/adminPanel/subcomponents/AddStoriesUserPanel'; import { AdminPanelStoriesUser, StoryListView, StoryView } from '../StoriesTypes'; +import { StoryCell } from '../StoriesTypes'; + +// config: +// chapter: 4 +// variant: default + +export const defaultHeader: string = `--- +env: + iterFib: + chapter: 2 + variant: default + recuFib: + chapter: 4 + variant: default + rune: + chapter: 4 + variant: default`; + +export const defaultContent: StoryCell[] = [ + { + // id: 0, + index: 0, + isCode: true, + env: 'iterFib', + content: `function print(message) { + display(message); +} +draw_data(list(1, 2, 3, 4)); +display("hello world1"); +` + }, + { + // id: 1, + index: 1, + isCode: false, + env: '', + content: `# Hello world! +## hello world!! +hello world!!! +hello world!!! +\`\`\`\` +\`\`\`{source} +print("hello world") +\`\`\` +\`\`\`\` +` + }, + { + // id: 2, + index: 2, + isCode: true, + env: 'recuFib', + content: `print("source academy stories"); +` + }, + { + // id: 3, + index: 3, + isCode: true, + env: 'iterFib', + content: `print("hello world"); +` + }, + { + // id: 4, + index: 4, + isCode: true, + env: 'iterFib', + content: `print("why this cell?"); +` + } +]; + +let tempHeader = defaultHeader; +let tempContent = defaultContent; // Helpers @@ -83,7 +159,8 @@ export const getStories = async (tokens: Tokens): Promise ({ ...story, header: tempContent, content: tempContent })); }; export const getStory = async (tokens: Tokens, storyId: number): Promise => { @@ -95,7 +172,13 @@ export const getStory = async (tokens: Tokens, storyId: number): Promise => { const resp = await requestStoryBackend(`/groups/${getStoriesGroupId()}/stories`, 'POST', { - body: { authorId, title, content, pinOrder }, + body: { authorId, title, defaultStoryContent, pinOrder }, ...tokens }); if (!resp) { @@ -123,11 +207,12 @@ export const updateStory = async ( tokens: Tokens, id: number, title: string, - content: string, + header: string, + content: StoryCell[], pinOrder: number | null ): Promise => { const resp = await requestStoryBackend(`/groups/${getStoriesGroupId()}/stories/${id}`, 'PUT', { - body: { title, content, pinOrder }, + body: { title, defaultStoryContent, pinOrder }, ...tokens }); if (!resp) { @@ -136,7 +221,15 @@ export const updateStory = async ( } showSuccessMessage('Story saved'); const updatedStory = await resp.json(); - return updatedStory; + // return updatedStory; + + // change + console.log('in updateStory'); + tempContent = content; + tempHeader = header; + console.log(content, header); + const story = { ...updatedStory, content: content, header: header }; + return story; }; // Returns the deleted story, or null if errors occur diff --git a/src/features/stories/storiesComponents/CreateStoryCell.tsx b/src/features/stories/storiesComponents/CreateStoryCell.tsx new file mode 100644 index 0000000000..ace21ad358 --- /dev/null +++ b/src/features/stories/storiesComponents/CreateStoryCell.tsx @@ -0,0 +1,131 @@ +import { Menu, MenuItem } from '@blueprintjs/core'; +import { useState } from 'react'; +import AceEditor from 'react-ace'; +import { useDispatch } from 'react-redux'; +import { ControlButtonSaveButton } from 'src/commons/controlBar/ControlBarSaveButton'; +import { useTypedSelector } from 'src/commons/utils/Hooks'; +import { showWarningMessage } from 'src/commons/utils/notifications/NotificationsHelper'; + +import StoriesActions from '../StoriesActions'; +import { StoryCell } from '../StoriesTypes'; +import { getEnvironments } from './UserBlogContent'; + +type Props = { + index: number; +}; + +const NewStoryCell: React.FC = ({ index }) => { + const dispatch = useDispatch(); + const { currentStory: story, currentStoryId: storyId } = useTypedSelector(store => store.stories); + const envs = getEnvironments(story!.header); + const [isCode, setIsCode] = useState(false); + const [env, setEnv] = useState(envs[0]); + const [code, setCode] = useState(''); + const [isDirty, setIsDirty] = useState(false); + + if (!story) { + return
; + } + + const editorOnChange = (code: string) => { + setCode(code); + setIsDirty(code.trim() !== ''); + }; + + const reset = () => { + setCode(''); + setEnv(envs[0]); + setIsCode(false); + setIsDirty(false); + }; + + const saveNewStoryCell = () => { + const contents = story.content; + for (let i = index; i < contents.length; i++) { + contents[i].index += 1; + } + const newContent: StoryCell = { + index: index, + isCode: isCode, + env: isCode ? env : '', + content: code + }; + contents.push(newContent); + contents.sort((a, b) => a.index - b.index); + const newStory = { ...story, content: [...contents] }; + console.log('a new cell is saved'); + console.log(newStory); + dispatch(StoriesActions.setCurrentStory({ ...newStory })); + dispatch(StoriesActions.saveStory(newStory, storyId!)); + }; + + const saveButClicked = () => { + if (!isDirty) { + showWarningMessage('Cannot save empty story cell!'); + return; + } + saveNewStoryCell(); + reset(); + }; + + return ( +
+
+ + + + setIsCode(false)} text="Markdown" /> + setIsCode(true)} text="Source" /> + + + {isCode && ( +
+ + + {envs.map((env: string, index: number) => ( + { + setEnv(env); + }} + text={env} + /> + ))} + + +
+ )} +
+ +
+ ); +}; + +export default NewStoryCell; diff --git a/src/features/stories/storiesComponents/Draggable.tsx b/src/features/stories/storiesComponents/Draggable.tsx new file mode 100644 index 0000000000..2e75888bd8 --- /dev/null +++ b/src/features/stories/storiesComponents/Draggable.tsx @@ -0,0 +1,51 @@ +import React, { useRef } from 'react'; + +import { useDragItem } from '../DragContext'; + +interface DraggableProps { + children: React.ReactNode; + id: number; +} + +const Draggable: React.FC = ({ children, id }) => { + const elementRef = useRef(null); + const { setIndex } = useDragItem(); + + const handleDragStart = (e: React.DragEvent) => { + if (elementRef.current) { + elementRef.current.style.opacity = '0.7'; + // Critical: Set the drag image to be just this element + e.dataTransfer.setDragImage(elementRef.current, 0, 0); + setIndex(id); + } + + // Set the effect + e.dataTransfer.effectAllowed = 'move'; + + // Prevent text selection during drag + document.body.style.userSelect = 'none'; + }; + + const handleDragEnd = () => { + // Re-enable text selection + if (elementRef.current) { + elementRef.current.style.opacity = '1'; + } + document.body.style.userSelect = ''; + }; + + return ( +
+ {children} +
+ ); +}; + +export default Draggable; diff --git a/src/features/stories/storiesComponents/DropArea.tsx b/src/features/stories/storiesComponents/DropArea.tsx new file mode 100644 index 0000000000..c3dee9a817 --- /dev/null +++ b/src/features/stories/storiesComponents/DropArea.tsx @@ -0,0 +1,65 @@ +import { useState } from 'react'; +import { useDispatch } from 'react-redux'; +import { useTypedSelector } from 'src/commons/utils/Hooks'; + +import { useDragItem } from '../DragContext'; +import StoriesActions from '../StoriesActions'; + +interface DropAreaProps { + dropIndex: number; +} + +const DropArea: React.FC = ({ dropIndex }) => { + const [showDrop, setShowDrop] = useState(false); + const dragItem = useDragItem(); + const { currentStory: story, currentStoryId: storyId } = useTypedSelector(store => store.stories); + const dispatch = useDispatch(); + + if (!story) { + // will never reached here, as story has been checked in Story.tsx + return
; + } + + const onDrop = () => { + const contents = story!.content; + const dragIndex = dragItem!.index!; + if (dragIndex > dropIndex) { + console.log('front'); + for (let i = dropIndex + 1; i < dragIndex; i++) { + contents[i].index += 1; + } + contents[dragIndex].index = dropIndex + 1; + } else if (dragIndex < dropIndex) { + console.log('back'); + console.log(dragIndex, dropIndex); + for (let i = dragIndex + 1; i <= dropIndex; i++) { + contents[i].index -= 1; + } + contents[dragIndex].index = dropIndex; + } else { + return; + } + contents.sort((a, b) => a.index - b.index); + console.log(contents); + const newStory = { ...story, content: [...contents] }; + dispatch(StoriesActions.setCurrentStory(newStory)); + dispatch(StoriesActions.saveStory(newStory, storyId!)); + }; + + return ( +
setShowDrop(true)} + onDragLeave={() => setShowDrop(false)} + onDrop={() => { + onDrop(); + setShowDrop(false); + }} + onDragOver={e => e.preventDefault()} + > + Drop Here +
+ ); +}; + +export default DropArea; diff --git a/src/features/stories/storiesComponents/EditStoryCell.tsx b/src/features/stories/storiesComponents/EditStoryCell.tsx new file mode 100644 index 0000000000..eca00ab70c --- /dev/null +++ b/src/features/stories/storiesComponents/EditStoryCell.tsx @@ -0,0 +1,264 @@ +import { Button } from '@blueprintjs/core'; +import { createContext, useEffect, useState } from 'react'; +import AceEditor from 'react-ace'; +import { useDispatch } from 'react-redux'; +import { ControlButtonSaveButton } from 'src/commons/controlBar/ControlBarSaveButton'; +import { showSimpleConfirmDialog } from 'src/commons/utils/DialogHelper'; +import { useTypedSelector } from 'src/commons/utils/Hooks'; +import { renderStoryMarkdown } from 'src/commons/utils/StoriesHelper'; + +import StoriesActions from '../StoriesActions'; +import { StoryCell } from '../StoriesTypes'; +import NewStoryCell from './CreateStoryCell'; +import Draggable from './Draggable'; +import DropArea from './DropArea'; + +type Props = { + index: number; +}; + +export const SourceBlockContext = createContext<(isTyping: boolean) => void>(() => {}); + +function EditStoryCell(props: Props) { + const dispatch = useDispatch(); + const { currentStory: story, currentStoryId: storyId } = useTypedSelector(store => store.stories); + const [isCode, setIsCode] = useState(false); + const [env, setEnv] = useState(''); + const [storyContent, setStoryContent] = useState(''); + const [isEditMode, setEditMode] = useState(false); + const [isDirty, setIsDirty] = useState(false); + const [showButs, setShowButs] = useState(false); + const [showNewCellUp, setShowNewCellUp] = useState(false); + const [showNewCellDown, setShowNewCellDown] = useState(false); + + useEffect(() => { + if (!story) return; + setStoryContent(story.content[props.index].content); + setEnv(story.content[props.index].env); + setIsCode(story.content[props.index].isCode); + console.log(story.content[props.index], props.index, isCode); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [story]); + + if (!story) { + // will never reach here, as it has been checked in Story.tsx + return
; + } + + const editContent = (newContent: string) => { + console.log('content is editted'); + const contents = story.content; + contents.filter((story: StoryCell) => story.index == props.index)[0].content = newContent; + const newStory = { ...story, content: [...contents] }; + dispatch(StoriesActions.setCurrentStory(newStory)); + dispatch(StoriesActions.saveStory(newStory, storyId!)); + }; + + const saveButClicked = () => { + setEditMode(false); + setIsDirty(false); + setShowButs(false); + setShowNewCellUp(false); + setShowNewCellDown(false); + if (storyContent.trim().length > 0) { + const trimmedContent = storyContent.trim(); + setStoryContent(trimmedContent); + editContent(trimmedContent); + } else { + deleteWithoutConfirmation(); + } + }; + + const deleteWithConfirmation = async () => { + const confirm = await showSimpleConfirmDialog({ + contents: ( + <> +

Delete the story cell?

+

Note: This action is irreversible.

+ + ), + positiveIntent: 'danger', + positiveLabel: 'Delete' + }); + if (!confirm) { + return; + } + deleteWithoutConfirmation(); + }; + + const deleteWithoutConfirmation = () => { + console.log(`story cell ${props.index} is deleted`); + const contents = story.content; + const newContents = []; + for (let i = 0; i < contents.length; i++) { + if (props.index === i) { + continue; + } else if (props.index < i) { + contents[i].index--; + } + newContents.push(contents[i]); + } + const newStory = { ...story, content: newContents }; + dispatch(StoriesActions.setCurrentStory(newStory)); + dispatch(StoriesActions.saveStory(newStory, storyId!)); + }; + + const onEditorValueChange = (content: string) => { + setStoryContent(content); + setIsDirty(true); + }; + + const handleDoubleClick = () => { + if (!isCode) { + setEditMode(true); + } + }; + + const moveStoryCell = (moveUp: boolean) => { + const swapIndex = props.index + (moveUp ? -1 : 1); + // check if the user is moving the story cell out of the array bound + if (swapIndex < 0 || swapIndex >= story.content.length) { + return; + } + if (moveUp) { + story.content[swapIndex].index++; + story.content[props.index].index--; + } else { + story.content[swapIndex].index--; + story.content[props.index].index++; + } + const temp = story.content[props.index]; + story.content[props.index] = story.content[swapIndex]; + story.content[swapIndex] = temp; + const newStory = { ...story, content: [...story.content] }; + dispatch(StoriesActions.setCurrentStory(newStory)); + dispatch(StoriesActions.saveStory(newStory, storyId!)); + }; + + return ( +
setShowButs(true)} + onMouseLeave={() => setShowButs(false)} + > + {showNewCellUp && } + {isEditMode && ( +
+ +
+ )} +
+ {showButs && ( +
+ + + + + + +
+ )} + {isEditMode ? ( + + ) : ( + + {renderStoryMarkdown( + isCode ? '```{source} env:' + env + '\n' + storyContent : storyContent, + props.index, + false + )} + + )} +
+ {showNewCellDown && } + +
+ ); +} + +export default EditStoryCell; diff --git a/src/features/stories/storiesComponents/SourceBlock.tsx b/src/features/stories/storiesComponents/SourceBlock.tsx index 86ecd97114..b4d7af73d8 100644 --- a/src/features/stories/storiesComponents/SourceBlock.tsx +++ b/src/features/stories/storiesComponents/SourceBlock.tsx @@ -1,4 +1,4 @@ -import { Card, Classes } from '@blueprintjs/core'; +import { Card, Classes, Menu, MenuItem } from '@blueprintjs/core'; import { IconNames } from '@blueprintjs/icons'; import { Chapter, Variant } from 'js-slang/dist/types'; import React, { useEffect, useRef, useState } from 'react'; @@ -6,6 +6,7 @@ import AceEditor from 'react-ace'; import { useDispatch } from 'react-redux'; import { ResultOutput, styliseSublanguage } from 'src/commons/application/ApplicationTypes'; import { ControlBarRunButton } from 'src/commons/controlBar/ControlBarRunButton'; +import { ControlButtonSaveButton } from 'src/commons/controlBar/ControlBarSaveButton'; import ControlButton from 'src/commons/ControlButton'; import makeDataVisualizerTabFrom from 'src/commons/sideContent/content/SideContentDataVisualizer'; import makeHtmlDisplayTabFrom from 'src/commons/sideContent/content/SideContentHtmlDisplay'; @@ -21,11 +22,14 @@ import { makeSubstVisualizerTabFrom } from 'src/pages/playground/PlaygroundTabs' import { ExternalLibraryName } from '../../../commons/application/types/ExternalTypes'; import { Output } from '../../../commons/repl/Repl'; import { getModeString, selectMode } from '../../../commons/utils/AceHelper'; -import { DEFAULT_ENV } from './UserBlogContent'; +import { StoryCell } from '../StoriesTypes'; +import { DEFAULT_ENV, getEnvironments, handleHeaders } from './UserBlogContent'; export type SourceBlockProps = { content: string; commands: string; // env is in commands + index: number; + isViewOnly: boolean; }; /** @@ -50,8 +54,11 @@ const SourceBlock: React.FC = props => { const dispatch = useDispatch(); const [code, setCode] = useState(props.content); const [outputIndex, setOutputIndex] = useState(Infinity); - + const [isDirty, setIsDirty] = useState(false); + const { currentStory: story, currentStoryId: storyId } = useTypedSelector(store => store.stories); const envList = useTypedSelector(store => Object.keys(store.stories.envs)); + const { header: header } = story!; + const envs = getEnvironments(header); // setting env const commandsEnv = parseMetadata('env', props.commands); @@ -69,13 +76,39 @@ const SourceBlock: React.FC = props => { store => store.stories.envs[env]?.context.variant || Constants.defaultSourceVariant ); + const [currentEnv, setCurrentEnv] = useState(env); + const [currentChapter, setCurrentChapter] = useState(chapter); + + const getChapter = () => { + const envIndex = envs.indexOf(env); + // number indicating the chapter start from index 13 + return parseInt(header.split(`\n`)[envIndex * 3 + 3].substring(13)); + }; + useEffect(() => { setCode(props.content); }, [props.content]); + useEffect(() => { + setCurrentChapter(getChapter()); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [story]); + const output = useTypedSelector(store => store.stories.envs[env]?.output || []); const { selectedTab, setSelectedTab } = useSideContent(`stories.${env}`); + // useEffect(() => { + // if (!selectedTab) { + // console.log("hello"); + // console.log(setSelectedTab); + // setSelectedTab(SideContentType.storiesRun); + // } + // }, []); + + useEffect(() => { + console.log('selected tab is changed: ', selectedTab); + }, [selectedTab]); + const onChangeTabs = React.useCallback( ( newTabId: SideContentType, @@ -87,11 +120,13 @@ const SourceBlock: React.FC = props => { dispatch( StoriesActions.toggleStoriesUsingSubst(newTabId === SideContentType.substVisualizer, env) ); + console.log(selectedTab); + console.log('selected tab: ', newTabId); setSelectedTab(newTabId); }, // eslint-disable-next-line react-hooks/exhaustive-deps - [] + [selectedTab] ); const envDisplayLabel = @@ -191,7 +226,9 @@ const SourceBlock: React.FC = props => { // is handled by the component setting. if (selectedTab) onChangeTabs(selectedTab, selectedTab, {} as any); + console.log('Running on ', selectedTab); dispatch(StoriesActions.evalStory(env, code)); + console.log(selectedTab); setOutputIndex(output.length); }; @@ -203,6 +240,68 @@ const SourceBlock: React.FC = props => { dispatch(StoriesActions.clearStoryEnv(env)); }; + const editorOnChange = (code: string) => { + setCode(code); + setIsDirty(true); + }; + + const deleteStoryCell = (contents: StoryCell[]) => { + console.log(`story cell ${props.index} is deleted`); + for (let i = props.index + 1; i < contents.length; i++) { + contents[i].index--; + } + contents.splice(props.index, 1); + }; + + const editHeader = (header: string[]) => { + console.log('In source block: chapter is editted'); + const index = envList.indexOf(currentEnv); + header[index * 3 + 3] = ` chapter: ${currentChapter}`; + return header; + }; + + const saveButClicked = () => { + setIsDirty(false); + const trimmedCode = code.trim(); + setCode(trimmedCode); + const contents = [...story!.content]; + if (trimmedCode.length === 0) { + deleteStoryCell(contents); + } else { + contents[props.index].content = trimmedCode; + } + if (currentEnv !== env) { + // set a new env + console.log('In source block: env is editted'); + console.log(currentEnv, env); + story!.content[props.index].env = currentEnv; + } + const newHeader = story!.header.split('\n'); + if (currentChapter !== chapter) { + // set a new chapter for the corresponding env, all source block with the same env will change tgt + console.log(currentChapter, chapter); + editHeader(newHeader); + } + execResetEnv(); + handleHeaders(newHeader.join('\n')); + const newStory = { ...story!, content: contents, header: newHeader.join('\n') }; + dispatch(StoriesActions.setCurrentStory(newStory)); + dispatch(StoriesActions.saveStory(newStory, storyId!)); + }; + + const changeEnv = (env: string) => { + setCurrentEnv(env); + const header = story!.header.split('\n'); + const index = envList.indexOf(env); + setCurrentChapter(+header[3 + index * 3].substring(13)); + setIsDirty(true); + }; + + const changeEnvChapter = (chapter: Chapter) => { + setCurrentChapter(chapter); + setIsDirty(true); + }; + selectMode(chapter, variant, ExternalLibraryName.NONE); return ( @@ -210,13 +309,52 @@ const SourceBlock: React.FC = props => {
- + {isDirty ? ( + + ) : ( + + )} - {envDisplayLabel} + {props.isViewOnly ? ( + envDisplayLabel + ) : ( +
+ + + {envList.map((env: string, index: number) => ( + changeEnv(env)} /> + ))} + + +

|

+ + + {[1, 2, 3, 4].map((chapter: Chapter, index: number) => ( + changeEnvChapter(chapter)} + text={styliseSublanguage(chapter, variant)} + /> + ))} + + +
+ )}
@@ -230,7 +368,9 @@ const SourceBlock: React.FC = props => { height="1px" width="100%" value={code} - onChange={code => setCode(code)} + onChange={editorOnChange} + // onFocus={() => setIsTyping(true)} + // onBlur={() => setIsTyping(false)} commands={[ { name: 'evaluate', diff --git a/src/features/stories/storiesComponents/UserBlogContent.tsx b/src/features/stories/storiesComponents/UserBlogContent.tsx index 2cabf33bc4..1c83284787 100644 --- a/src/features/stories/storiesComponents/UserBlogContent.tsx +++ b/src/features/stories/storiesComponents/UserBlogContent.tsx @@ -1,13 +1,25 @@ +import { Menu, MenuItem } from '@blueprintjs/core'; +import { TextInput } from '@tremor/react'; import { Chapter, Variant } from 'js-slang/dist/types'; import yaml from 'js-yaml'; import React, { useEffect, useState } from 'react'; import debounceRender from 'react-debounce-render'; +import { useDispatch } from 'react-redux'; +import { styliseSublanguage } from 'src/commons/application/ApplicationTypes'; +import ControlBar, { ControlBarProps } from 'src/commons/controlBar/ControlBar'; +import { ControlButtonSaveButton } from 'src/commons/controlBar/ControlBarSaveButton'; import Constants from 'src/commons/utils/Constants'; +import { useTypedSelector } from 'src/commons/utils/Hooks'; import { propsAreEqual } from 'src/commons/utils/MemoizeHelper'; -import { renderStoryMarkdown } from 'src/commons/utils/StoriesHelper'; +import { showWarningMessage } from 'src/commons/utils/notifications/NotificationsHelper'; import StoriesActions from 'src/features/stories/StoriesActions'; import { store } from '../../../pages/createStore'; +import { DragContext } from '../DragContext'; +import NewStoryCell from './CreateStoryCell'; +import DropArea from './DropArea'; +import EditStoryCell from './EditStoryCell'; +import ViewStoryCell from './ViewStoryCell'; export const DEFAULT_ENV = 'default'; @@ -33,7 +45,7 @@ function handleEnvironment(envConfig: Record): void { } } -function handleHeaders(headers: string): void { +export function handleHeaders(headers: string): void { if (headers === '') { store.dispatch( StoriesActions.addStoryEnv( @@ -61,7 +73,6 @@ function handleHeaders(headers: string): void { } } } catch (err) { - console.warn(err); if (err instanceof yaml.YAMLException) { // default headers store.dispatch( @@ -91,23 +102,154 @@ export function getYamlHeader(content: string): { header: string; content: strin }; } +export function getEnvironments(header: string): string[] { + const environments: string[] = []; + const temp = header.split('\n'); + for (let i = 2; i < temp.length - 1; i += 3) { + environments.push(temp[i].substring(2, temp[i].length - 1)); + } + return environments; +} + +export function constructHeader( + header: string, + env: string, + chapter: Chapter, + variant: Variant +): string { + const newHeader: string[] = header.split('\n'); + newHeader.push(` ${env}:`); + newHeader.push(` chapter: ${chapter}`); + newHeader.push(` variant: ${variant}`); + return newHeader.join('\n'); +} + type Props = { - fileContent: string; + isViewOnly: boolean; }; -const UserBlogContent: React.FC = ({ fileContent }) => { - const [content, setContent] = useState(''); +const UserBlogContent: React.FC = ({ isViewOnly }) => { + const [newEnv, setNewEnv] = useState(''); + // TODO: enable different variant + const variant: Variant = Variant.DEFAULT; + const [currentChapter, setEnvChapter] = useState(Chapter.SOURCE_1); + const [isDirty, setIsDirty] = useState(false); + const dispatch = useDispatch(); + const { currentStory: story, currentStoryId: storyId } = useTypedSelector(store => store.stories); + const { content: contents, header: header } = story!; + const [envs, setEnvs] = useState(getEnvironments(header)); + const [activeIndex, setActiveIndex] = useState(null); useEffect(() => { - const { header, content } = getYamlHeader(fileContent); - setContent(content); store.dispatch(StoriesActions.clearStoryEnv()); handleHeaders(header); - }, [fileContent]); + setEnvs(getEnvironments(header)); + console.log('header resets'); + }, [header]); + + if (!story) { + // will never reach here, as it has been check in Story.tsx + return
; + } + + const editHeader = (newHeader: string) => { + console.log('header is editted'); + const newStory = { ...story, header: newHeader }; + dispatch(StoriesActions.setCurrentStory(newStory)); + dispatch(StoriesActions.saveStory(newStory, storyId!)); + }; + + const saveButClicked = () => { + setNewEnv(''); + setIsDirty(false); + if (newEnv.trim() === '') { + showWarningMessage('environment name cannot be empty'); + return; + } else if (envs.includes(newEnv)) { + showWarningMessage(`${newEnv} already exists!`); + return; + } + const newHeader = header.concat(` + ${newEnv}: + chapter: ${currentChapter} + variant: default`); + editHeader(newHeader); + }; + + const controlBarProps: ControlBarProps = { + editorButtons: [ +
+ { + setNewEnv(e.target.value); + if (e.target.value.trim() !== '') { + setIsDirty(true); + } else { + setIsDirty(false); + } + }} + /> + + + setEnvChapter(1)} + text={styliseSublanguage(Chapter.SOURCE_1, variant)} + /> + setEnvChapter(2)} + text={styliseSublanguage(Chapter.SOURCE_2, variant)} + /> + setEnvChapter(3)} + text={styliseSublanguage(Chapter.SOURCE_3, variant)} + /> + setEnvChapter(4)} + text={styliseSublanguage(Chapter.SOURCE_4, variant)} + /> + + + +
+ ] + }; - return content ? ( + return contents.length > 0 ? (
-
{renderStoryMarkdown(content)}
+ {!isViewOnly && } + {isViewOnly ? ( + contents.map((story, key) => ) + ) : ( + +
+ +
+ {contents.map((_, key) => { + return ; + })} +
+ )} + {!isViewOnly && ( +
+ +
+ )}
) : (
diff --git a/src/features/stories/storiesComponents/ViewStoryCell.tsx b/src/features/stories/storiesComponents/ViewStoryCell.tsx new file mode 100644 index 0000000000..d13072bdcf --- /dev/null +++ b/src/features/stories/storiesComponents/ViewStoryCell.tsx @@ -0,0 +1,24 @@ +import { useEffect, useState } from 'react'; +import { renderStoryMarkdown } from 'src/commons/utils/StoriesHelper'; + +import { StoryCell } from '../StoriesTypes'; + +type Props = { + story: StoryCell; +}; + +function ViewStoryCell(props: Props) { + const { index, isCode, env, content } = props.story; + const [storyContent, setStoryContent] = useState(content); + + useEffect(() => { + if (isCode) { + setStoryContent('```{source} env:' + env + '\n' + content); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + return
{renderStoryMarkdown(storyContent, index, true)}
; +} + +export default ViewStoryCell; diff --git a/src/pages/stories/Stories.tsx b/src/pages/stories/Stories.tsx index db96feadb9..cbeae44b56 100644 --- a/src/pages/stories/Stories.tsx +++ b/src/pages/stories/Stories.tsx @@ -10,7 +10,6 @@ import GradingText from 'src/commons/grading/GradingText'; import { showSimpleConfirmDialog } from 'src/commons/utils/DialogHelper'; import { useTypedSelector } from 'src/commons/utils/Hooks'; import StoriesActions from 'src/features/stories/StoriesActions'; -import { getYamlHeader } from 'src/features/stories/storiesComponents/UserBlogContent'; import StoriesTable from './StoriesTable'; import StoryActions from './StoryActions'; @@ -141,14 +140,11 @@ const Stories: React.FC = () => { ({ ...story, content: getYamlHeader(story.content).content })) - .filter( - story => - // Always show pinned stories - story.isPinned || story.authorName.toLowerCase().includes(query.toLowerCase()) - )} + stories={storyList.filter( + story => + // Always show pinned stories + story.isPinned || story.authorName.toLowerCase().includes(query.toLowerCase()) + )} storyActions={story => { const isAuthor = storiesUserId === story.authorId; const hasWritePermissions = diff --git a/src/pages/stories/StoriesTable.tsx b/src/pages/stories/StoriesTable.tsx index d66d73379a..c0a45d304a 100644 --- a/src/pages/stories/StoriesTable.tsx +++ b/src/pages/stories/StoriesTable.tsx @@ -50,7 +50,7 @@ const StoriesTable: React.FC = ({ headers, stories, storyActions }) => { flex: 6, field: 'content', headerName: 'Content', - valueFormatter: ({ value }) => truncate(value), + valueFormatter: ({ value }) => truncate(value[0].content), cellStyle: { textAlign: 'left' } }, { diff --git a/src/pages/stories/Story.tsx b/src/pages/stories/Story.tsx index 676eb59cc1..23e72bdbfb 100644 --- a/src/pages/stories/Story.tsx +++ b/src/pages/stories/Story.tsx @@ -1,15 +1,15 @@ import 'js-slang/dist/editors/ace/theme/source'; -import { Classes, InputGroup } from '@blueprintjs/core'; +import { Classes } from '@blueprintjs/core'; +import { TextInput } from '@tremor/react'; import classNames from 'classnames'; import { useEffect, useState } from 'react'; -import AceEditor, { IEditorProps } from 'react-ace'; import { useDispatch } from 'react-redux'; import { useParams } from 'react-router'; import ControlBar, { ControlBarProps } from 'src/commons/controlBar/ControlBar'; import { ControlButtonSaveButton } from 'src/commons/controlBar/ControlBarSaveButton'; import { useTypedSelector } from 'src/commons/utils/Hooks'; -import { scrollSync } from 'src/commons/utils/StoriesHelper'; +import { showWarningMessage } from 'src/commons/utils/notifications/NotificationsHelper'; import StoriesActions from 'src/features/stories/StoriesActions'; import UserBlogContent from '../../features/stories/storiesComponents/UserBlogContent'; @@ -24,6 +24,7 @@ const Story: React.FC = ({ isViewOnly = false }) => { const { currentStory: story, currentStoryId: storyId } = useTypedSelector(store => store.stories); const { id: idToSet } = useParams<{ id: string }>(); + useEffect(() => { // Clear screen on first load dispatch(StoriesActions.setCurrentStory(null)); @@ -37,27 +38,15 @@ const Story: React.FC = ({ isViewOnly = false }) => { return <>; } - const onEditorScroll = (e: IEditorProps) => { - const userblogContainer = document.getElementById('userblogContainer'); - if (userblogContainer) { - scrollSync(e, userblogContainer); - } - }; - - const onEditorValueChange = (val: string) => { - setIsDirty(true); - dispatch(StoriesActions.setCurrentStory({ ...story, content: val })); - }; - - const { title, content } = story; + const { title: title } = story; const controlBarProps: ControlBarProps = { editorButtons: [ isViewOnly ? ( <>{title} ) : ( - { @@ -71,6 +60,10 @@ const Story: React.FC = ({ isViewOnly = false }) => { { + if (story.title.trim() === '') { + showWarningMessage('story name cannot be empty'); + return; + } if (storyId) { // Update story dispatch(StoriesActions.saveStory(story, storyId)); @@ -79,6 +72,7 @@ const Story: React.FC = ({ isViewOnly = false }) => { dispatch(StoriesActions.createStory(story)); } // TODO: Set isDirty to false + setIsDirty(false); }} hasUnsavedChanges={isDirty} /> @@ -90,24 +84,8 @@ const Story: React.FC = ({ isViewOnly = false }) => {
- {!isViewOnly && ( - - )}
- +
diff --git a/src/styles/_stories.scss b/src/styles/_stories.scss index deb55d57ad..3bdcfd5de8 100644 --- a/src/styles/_stories.scss +++ b/src/styles/_stories.scss @@ -51,6 +51,7 @@ padding: 16px 24px; font-size: 1rem; line-height: 1.5; + // cursor: grab; & > * { margin: 0; @@ -93,6 +94,34 @@ pre code { white-space: pre; } + + [draggable='true'] { + user-select: none; + -webkit-user-select: none; + cursor: grab; + position: relative; /* Ensure proper positioning */ + } + + [draggable='true']:active { + cursor: grabbing; + } + + .drop-area { + width: 100%; + height: 100px; + columns: #dcdcdc; + border: 1px dashed #dcdcdc; + border-radius: 10px; + padding: 15px; + opacity: 1; + transition: all 0.2s ease-in-out; + } + + .hide-drop { + opacity: 0; + height: 30px; + margin: 0px; + } } } } diff --git a/yarn.lock b/yarn.lock index 0f524c8cb7..b83e3b14ea 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2054,6 +2054,55 @@ __metadata: languageName: node linkType: hard +"@dnd-kit/accessibility@npm:^3.1.1": + version: 3.1.1 + resolution: "@dnd-kit/accessibility@npm:3.1.1" + dependencies: + tslib: "npm:^2.0.0" + peerDependencies: + react: ">=16.8.0" + checksum: 10c0/be0bf41716dc58f9386bc36906ec1ce72b7b42b6d1d0e631d347afe9bd8714a829bd6f58a346dd089b1519e93918ae2f94497411a61a4f5e4d9247c6cfd1fef8 + languageName: node + linkType: hard + +"@dnd-kit/core@npm:^6.3.1": + version: 6.3.1 + resolution: "@dnd-kit/core@npm:6.3.1" + dependencies: + "@dnd-kit/accessibility": "npm:^3.1.1" + "@dnd-kit/utilities": "npm:^3.2.2" + tslib: "npm:^2.0.0" + peerDependencies: + react: ">=16.8.0" + react-dom: ">=16.8.0" + checksum: 10c0/196db95d81096d9dc248983533eab91ba83591770fa5c894b1ac776f42af0d99522b3fd5bb3923411470e4733fcfa103e6ee17adc17b9b7eb54c7fbec5ff7c52 + languageName: node + linkType: hard + +"@dnd-kit/sortable@npm:^10.0.0": + version: 10.0.0 + resolution: "@dnd-kit/sortable@npm:10.0.0" + dependencies: + "@dnd-kit/utilities": "npm:^3.2.2" + tslib: "npm:^2.0.0" + peerDependencies: + "@dnd-kit/core": ^6.3.0 + react: ">=16.8.0" + checksum: 10c0/37ee48bc6789fb512dc0e4c374a96d19abe5b2b76dc34856a5883aaa96c3297891b94cc77bbc409e074dcce70967ebcb9feb40cd9abadb8716fc280b4c7f99af + languageName: node + linkType: hard + +"@dnd-kit/utilities@npm:^3.2.2": + version: 3.2.2 + resolution: "@dnd-kit/utilities@npm:3.2.2" + dependencies: + tslib: "npm:^2.0.0" + peerDependencies: + react: ">=16.8.0" + checksum: 10c0/9aa90526f3e3fd567b5acc1b625a63177b9e8d00e7e50b2bd0e08fa2bf4dba7e19529777e001fdb8f89a7ce69f30b190c8364d390212634e0afdfa8c395e85a0 + languageName: node + linkType: hard + "@emotion/babel-plugin@npm:^11.11.0": version: 11.11.0 resolution: "@emotion/babel-plugin@npm:11.11.0" @@ -9431,6 +9480,9 @@ __metadata: "@blueprintjs/select": "npm:^5.1.3" "@convergencelabs/ace-collab-ext": "npm:^0.6.0" "@craco/craco": "npm:^7.1.0" + "@dnd-kit/core": "npm:^6.3.1" + "@dnd-kit/sortable": "npm:^10.0.0" + "@dnd-kit/utilities": "npm:^3.2.2" "@mantine/hooks": "npm:^7.11.2" "@octokit/rest": "npm:^20.0.0" "@reduxjs/toolkit": "npm:^1.9.7"