diff --git a/src/bus/AppBus.ts b/src/bus/AppBus.ts new file mode 100644 index 00000000..eeab717c --- /dev/null +++ b/src/bus/AppBus.ts @@ -0,0 +1,49 @@ +import { Listener, Message, Namespace } from '@/bus/Message'; + +enum Actions { + ElementClicked = 1, +} + +async function elementClicked( content: string ): Promise< void > { + return sendMessageToApp( { + action: Actions.ElementClicked, + payload: { content }, + } ); +} + +export const AppBus = { + namespace: `${ Namespace }_APP`, + actions: Actions, + listen, + stopListening, + elementClicked, +}; + +let listener: Listener; + +function listen( list: Listener ) { + stopListening(); + listener = ( message: Message ) => { + if ( message.namespace !== AppBus.namespace ) { + return; + } + list( message ); + }; + browser.runtime.onMessage.addListener( listener ); +} + +function stopListening() { + if ( listener ) { + browser.runtime.onMessage.removeListener( listener ); + } +} +async function sendMessageToApp( + message: Omit< Message, 'namespace' > +): Promise< void > { + const messageWithNamespace: Message = { + namespace: AppBus.namespace, + action: message.action, + payload: message.payload, + }; + await browser.runtime.sendMessage( messageWithNamespace ); +} diff --git a/src/bus/ContentBus.ts b/src/bus/ContentBus.ts new file mode 100644 index 00000000..36200f40 --- /dev/null +++ b/src/bus/ContentBus.ts @@ -0,0 +1,74 @@ +import { Listener, Message, Namespace } from '@/bus/Message'; + +enum Actions { + EnableHighlighting = 1, + DisableHighlighting, +} + +async function enableHighlighting(): Promise< void > { + return sendMessageToContent( { + action: Actions.EnableHighlighting, + payload: {}, + } ); +} + +async function disableHighlighting(): Promise< void > { + return sendMessageToContent( { + action: Actions.DisableHighlighting, + payload: {}, + } ); +} + +export const ContentBus = { + namespace: `${ Namespace }_CONTENT`, + actions: Actions, + listen, + stopListening, + enableHighlighting, + disableHighlighting, +}; + +let listener: Listener; + +function listen( list: Listener ) { + stopListening(); + listener = ( message: Message ) => { + if ( message.namespace !== ContentBus.namespace ) { + return; + } + list( message ); + }; + browser.runtime.onMessage.addListener( listener ); +} + +function stopListening() { + if ( listener ) { + browser.runtime.onMessage.removeListener( listener ); + } +} + +async function sendMessageToContent( + message: Omit< Message, 'namespace' > +): Promise< void > { + const currentTabId = await getCurrentTabId(); + if ( ! currentTabId ) { + throw Error( 'current tab not found' ); + } + const messageWithNamespace: Message = { + namespace: ContentBus.namespace, + action: message.action, + payload: message.payload, + }; + await browser.tabs.sendMessage( currentTabId, messageWithNamespace ); +} + +async function getCurrentTabId(): Promise< number | undefined > { + const tabs = await browser.tabs.query( { + currentWindow: true, + active: true, + } ); + if ( tabs.length !== 1 ) { + return; + } + return tabs[ 0 ]?.id; +} diff --git a/src/bus/Message.ts b/src/bus/Message.ts new file mode 100644 index 00000000..877815a1 --- /dev/null +++ b/src/bus/Message.ts @@ -0,0 +1,9 @@ +export const Namespace = 'TRY_WORDPRESS'; + +export interface Message { + namespace: string; + action: number; + payload: object; +} + +export type Listener = ( message: Message ) => void; diff --git a/src/extension/content.ts b/src/extension/content.ts index e69de29b..6b5a49a7 100644 --- a/src/extension/content.ts +++ b/src/extension/content.ts @@ -0,0 +1,85 @@ +import { Message } from '@/bus/Message'; +import { ContentBus } from '@/bus/ContentBus'; +import { AppBus } from '@/bus/AppBus'; + +let currentElement: HTMLElement | null = null; + +ContentBus.listen( ( message: Message ) => { + switch ( message.action ) { + case ContentBus.actions.EnableHighlighting: + document.body.addEventListener( 'mouseover', onMouseOver ); + document.body.addEventListener( 'mouseout', onMouseOut ); + document.body.addEventListener( 'click', onClick ); + enableHighlightingCursor(); + break; + case ContentBus.actions.DisableHighlighting: + document.body.removeEventListener( 'mouseover', onMouseOver ); + document.body.removeEventListener( 'mouseout', onMouseOut ); + document.body.removeEventListener( 'click', onClick ); + disableHighlightingCursor(); + removeStyle(); + break; + default: + console.error( `Unknown action: ${ message.action }` ); + break; + } +} ); + +function onClick( event: MouseEvent ) { + event.preventDefault(); + const element = event.target as HTMLElement; + if ( ! element ) { + return; + } + const clone = element.cloneNode( true ) as HTMLElement; + clone.style.outline = ''; + let content = clone.outerHTML.trim(); + content = content.replaceAll( ' style=""', '' ); + void AppBus.elementClicked( content ); +} + +function onMouseOver( event: MouseEvent ) { + const element = event.target as HTMLElement | null; + if ( ! element ) { + return; + } + currentElement = element; + currentElement.style.outline = '1px solid blue'; +} + +function onMouseOut( event: MouseEvent ) { + const element = event.target as HTMLElement | null; + if ( ! element ) { + return; + } + removeStyle(); + currentElement = null; +} + +function removeStyle() { + if ( ! currentElement ) { + return; + } + currentElement.style.outline = ''; +} + +const cursorStyleId = 'hover-highlighter-style'; + +function enableHighlightingCursor() { + let style = document.getElementById( cursorStyleId ); + if ( style ) { + // The highlighting cursor is already enabled. + return; + } + style = document.createElement( 'style' ); + style.id = cursorStyleId; + style.textContent = '* { cursor: crosshair !important; }'; + document.head.append( style ); +} + +function disableHighlightingCursor() { + const style = document.getElementById( cursorStyleId ); + if ( style ) { + style.remove(); + } +} diff --git a/src/ui/App.tsx b/src/ui/App.tsx index 6cf4322c..6980ac01 100644 --- a/src/ui/App.tsx +++ b/src/ui/App.tsx @@ -20,11 +20,14 @@ import { getSession, listSessions, Session } from '@/storage/session'; import { PlaceholderPreview } from '@/ui/preview/PlaceholderPreview'; import { SessionContext, SessionProvider } from '@/ui/session/SessionProvider'; import { ApiClient } from '@/api/ApiClient'; +import { BlogPostFlow } from '@/ui/flows/blog-post/BlogPostFlow'; export const Screens = { home: () => '/start/home', newSession: () => '/start/new-session', - viewSession: ( id: string ) => `/session/${ id }`, + viewSession: ( sessionId: string ) => `/session/${ sessionId }`, + flowBlogPost: ( sessionId: string ) => + `/session/${ sessionId }/flow/blog-post`, }; const homeLoader: LoaderFunction = async () => { @@ -63,6 +66,9 @@ function Routes( props: { initialScreen: string } ) { loader={ sessionLoader } > } /> + + } /> + ); diff --git a/src/ui/flows/blog-post/BlogPostFlow.tsx b/src/ui/flows/blog-post/BlogPostFlow.tsx new file mode 100644 index 00000000..ed0c552f --- /dev/null +++ b/src/ui/flows/blog-post/BlogPostFlow.tsx @@ -0,0 +1,28 @@ +import { useState } from 'react'; +import { Start } from '@/ui/flows/blog-post/Start'; +import { SelectContent } from '@/ui/flows/blog-post/SelectContent'; +import { Finish } from '@/ui/flows/blog-post/Finish'; + +enum Steps { + start = 1, + selectContent, + finish, +} + +export function BlogPostFlow() { + const [ currentStep, setCurrentStep ] = useState( Steps.start ); + + return ( + <> + { currentStep !== Steps.start ? null : ( + setCurrentStep( Steps.selectContent ) } /> + ) } + { currentStep !== Steps.selectContent ? null : ( + setCurrentStep( Steps.finish ) } + /> + ) } + { currentStep !== Steps.finish ? null : } + + ); +} diff --git a/src/ui/flows/blog-post/Finish.tsx b/src/ui/flows/blog-post/Finish.tsx new file mode 100644 index 00000000..e8a7fd0d --- /dev/null +++ b/src/ui/flows/blog-post/Finish.tsx @@ -0,0 +1,7 @@ +export function Finish() { + return ( + <> + The post has been imported + + ); +} diff --git a/src/ui/flows/blog-post/SelectContent.tsx b/src/ui/flows/blog-post/SelectContent.tsx new file mode 100644 index 00000000..5931fe86 --- /dev/null +++ b/src/ui/flows/blog-post/SelectContent.tsx @@ -0,0 +1,157 @@ +import { useEffect, useState } from 'react'; +import { AppBus } from '@/bus/AppBus'; +import { Message } from '@/bus/Message'; +import { ContentBus } from '@/bus/ContentBus'; + +enum section { + title = 1, + date, + content, +} + +export function SelectContent( props: { onExit: () => void } ) { + const { onExit } = props; + const [ title, setTitle ] = useState< string >(); + const [ content, setContent ] = useState< string >(); + const [ date, setDate ] = useState< string >(); + const [ lastClickedElement, setLastClickedElement ] = useState< string >(); + const [ waitingForSelection, setWaitingForSelection ] = useState< + section | false + >( false ); + + useEffect( () => { + AppBus.listen( async ( message: Message ) => { + switch ( message.action ) { + case AppBus.actions.ElementClicked: + await ContentBus.disableHighlighting(); + setLastClickedElement( ( message.payload as any ).content ); + } + } ); + return () => { + void ContentBus.disableHighlighting(); + AppBus.stopListening(); + }; + }, [] ); + + if ( lastClickedElement ) { + switch ( waitingForSelection ) { + case section.title: + setTitle( lastClickedElement ); + break; + case section.date: + setDate( lastClickedElement ); + break; + case section.content: + setContent( lastClickedElement ); + break; + } + setWaitingForSelection( false ); + setLastClickedElement( undefined ); + } + + const isValid = title && date && content; + + return ( + <> +
Select the content of the post
+ +
{ + setWaitingForSelection( isWaiting ? section.title : false ); + } } + /> +
{ + setWaitingForSelection( isWaiting ? section.date : false ); + } } + /> +
{ + setWaitingForSelection( + isWaiting ? section.content : false + ); + } } + /> + + ); +} + +function Section( props: { + label: string; + value: string | undefined; + disabled: boolean; + waitingForSelection: boolean; + onWaitingForSelection: ( isWaiting: boolean ) => void; +} ) { + const { + label, + value, + disabled, + waitingForSelection, + onWaitingForSelection, + } = props; + + return ( +
+
+ { label }{ ' ' } + + { ! waitingForSelection ? null : ( + + ) } +
+
{ value ?? 'Not found' }
+
+ ); +} diff --git a/src/ui/flows/blog-post/Start.tsx b/src/ui/flows/blog-post/Start.tsx new file mode 100644 index 00000000..7724c66e --- /dev/null +++ b/src/ui/flows/blog-post/Start.tsx @@ -0,0 +1,20 @@ +import { ContentBus } from '@/bus/ContentBus'; + +export function Start( props: { onExit: () => void } ) { + const { onExit } = props; + return ( + <> +
+ Navigate to the page of the post you'd like to import +
+ + + ); +} diff --git a/src/ui/session/ViewSession.tsx b/src/ui/session/ViewSession.tsx index 4f6a1597..7bdea24d 100644 --- a/src/ui/session/ViewSession.tsx +++ b/src/ui/session/ViewSession.tsx @@ -1,31 +1,26 @@ import { useSessionContext } from '@/ui/session/SessionProvider'; -import { Post } from '@/api/ApiClient'; -import { useEffect, useState } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { Screens } from '@/ui/App'; export function ViewSession() { - const { session, apiClient } = useSessionContext(); - - const [ posts, setPosts ] = useState< Post[] >( [] ); - useEffect( () => { - if ( ! apiClient ) { - return; - } - const getPosts = async () => { - setPosts( await apiClient.getPosts() ); - }; - void getPosts(); - }, [ apiClient?.siteUrl ] ); + const { session } = useSessionContext(); + const navigate = useNavigate(); return ( <> -
view session: { session.id }
- { apiClient?.siteUrl ? ( -
url: { apiClient.siteUrl }
- ) : null } +

+ { session.title } ({ session.url }) +

    - { posts.map( ( post ) => { - return
  • { post.title }
  • ; - } ) } +
  • + +
);