| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,36 @@ | ||
| import { CommandRuntime, CommandDeclaration, CommandContext } from '@joplin/lib/services/CommandService'; | ||
| import { _ } from '@joplin/lib/locale'; | ||
| import { stateUtils } from '@joplin/lib/reducer'; | ||
| import Note from '@joplin/lib/models/Note'; | ||
| import { createAppDefaultWindowState } from '../app.reducer'; | ||
| import Setting from '@joplin/lib/models/Setting'; | ||
|
|
||
| export const declaration: CommandDeclaration = { | ||
| name: 'openNoteInNewWindow', | ||
| label: () => _('Edit in new window'), | ||
| iconName: 'icon-share', | ||
| }; | ||
|
|
||
| let idCounter = 0; | ||
|
|
||
| export const runtime = (): CommandRuntime => { | ||
| return { | ||
| execute: async (context: CommandContext, noteId: string = null) => { | ||
| noteId = noteId || stateUtils.selectedNoteId(context.state); | ||
|
|
||
| const note = await Note.load(noteId, { fields: ['parent_id'] }); | ||
|
There was a problem hiding this comment. I think noteId could be undefined? If there's no note in the notebook for example There was a problem hiding this comment. The There was a problem hiding this comment. No you're right, with the condition the note should indeed be defined so the exception is not necessary. Could you remove it please? There was a problem hiding this comment. Resolved in 9919dca. |
||
| context.dispatch({ | ||
| type: 'WINDOW_OPEN', | ||
| noteId, | ||
| folderId: note.parent_id, | ||
| windowId: `window-${noteId}-${idCounter++}`, | ||
| defaultAppWindowState: { | ||
| ...createAppDefaultWindowState(), | ||
| noteVisiblePanes: Setting.value('noteVisiblePanes'), | ||
| editorCodeView: Setting.value('editor.codeView'), | ||
| }, | ||
| }); | ||
| }, | ||
| enabledCondition: 'oneNoteSelected', | ||
| }; | ||
| }; | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,172 @@ | ||
| import { defaultWindowId } from '@joplin/lib/reducer'; | ||
| import shim from '@joplin/lib/shim'; | ||
| import * as React from 'react'; | ||
| import { useState, useEffect, useRef, createContext } from 'react'; | ||
| import { createPortal } from 'react-dom'; | ||
| import { SecondaryWindowApi } from '../utils/window/types'; | ||
|
|
||
| // This component uses react-dom's Portals to render its children in a different HTML | ||
| // document. As children are rendered in a different Window/Document, they should avoid | ||
| // referencing the `window` and `document` globals. Instead, HTMLElement.ownerDocument | ||
| // and refs can be used to access the child component's DOM. | ||
|
|
||
| export const WindowIdContext = createContext(defaultWindowId); | ||
|
|
||
| type OnCloseCallback = ()=> void; | ||
| type OnFocusCallback = ()=> void; | ||
|
|
||
| export enum WindowMode { | ||
| Iframe, NewWindow, | ||
| } | ||
|
|
||
| interface Props { | ||
| // Note: children will be rendered in a different DOM from this node. Avoid using document.* methods | ||
| // in child components. | ||
| children: React.ReactNode[]|React.ReactNode; | ||
| title: string; | ||
| mode: WindowMode; | ||
| windowId: string; | ||
| onClose: OnCloseCallback; | ||
| onFocus?: OnFocusCallback; | ||
| } | ||
|
|
||
| const useDocument = ( | ||
| mode: WindowMode, | ||
| iframeElement: HTMLIFrameElement|null, | ||
| onClose: OnCloseCallback, | ||
| ) => { | ||
| const [doc, setDoc] = useState<Document>(null); | ||
|
|
||
| const onCloseRef = useRef(onClose); | ||
| onCloseRef.current = onClose; | ||
|
|
||
| useEffect(() => { | ||
| let openedWindow: Window|null = null; | ||
| const unmounted = false; | ||
| if (iframeElement) { | ||
| setDoc(iframeElement?.contentWindow?.document); | ||
| } else if (mode === WindowMode.NewWindow) { | ||
| openedWindow = window.open('about:blank'); | ||
| setDoc(openedWindow.document); | ||
|
|
||
| // .onbeforeunload and .onclose events don't seem to fire when closed by a user -- rely on polling | ||
| // instead: | ||
| void (async () => { | ||
| while (!unmounted) { | ||
| await new Promise<void>(resolve => { | ||
| shim.setTimeout(() => resolve(), 2000); | ||
| }); | ||
|
|
||
| if (openedWindow?.closed) { | ||
| onCloseRef.current?.(); | ||
| openedWindow = null; | ||
| break; | ||
| } | ||
| } | ||
| })(); | ||
| } | ||
|
|
||
| return () => { | ||
| // Delay: Closing immediately causes Electron to crash | ||
| setTimeout(() => { | ||
| if (!openedWindow?.closed) { | ||
| openedWindow?.close(); | ||
| onCloseRef.current?.(); | ||
| openedWindow = null; | ||
| } | ||
| }, 200); | ||
|
|
||
| if (iframeElement && !openedWindow) { | ||
| onCloseRef.current?.(); | ||
| } | ||
| }; | ||
| }, [iframeElement, mode]); | ||
|
|
||
| return doc; | ||
| }; | ||
|
|
||
| type OnSetLoaded = (loaded: boolean)=> void; | ||
| const useDocumentSetup = (doc: Document|null, setLoaded: OnSetLoaded, onFocus?: OnFocusCallback) => { | ||
| const onFocusRef = useRef(onFocus); | ||
| onFocusRef.current = onFocus; | ||
|
|
||
| useEffect(() => { | ||
| if (!doc) return; | ||
|
|
||
| doc.open(); | ||
| doc.write('<!DOCTYPE html><html><head></head><body></body></html>'); | ||
| doc.close(); | ||
|
|
||
| const cssUrls = [ | ||
| 'style.min.css', | ||
| ]; | ||
|
|
||
| for (const url of cssUrls) { | ||
| const style = doc.createElement('link'); | ||
| style.rel = 'stylesheet'; | ||
| style.href = url; | ||
| doc.head.appendChild(style); | ||
| } | ||
|
|
||
| const jsUrls = [ | ||
| 'vendor/lib/smalltalk/dist/smalltalk.min.js', | ||
| './utils/window/eventHandlerOverrides.js', | ||
| ]; | ||
| for (const url of jsUrls) { | ||
| const script = doc.createElement('script'); | ||
| script.src = url; | ||
| doc.head.appendChild(script); | ||
| } | ||
|
|
||
| doc.body.style.height = '100vh'; | ||
|
|
||
| const containerWindow = doc.defaultView; | ||
| containerWindow.addEventListener('focus', () => { | ||
| onFocusRef.current?.(); | ||
| }); | ||
| if (doc.hasFocus()) { | ||
| onFocusRef.current?.(); | ||
| } | ||
|
|
||
| setLoaded(true); | ||
| }, [doc, setLoaded]); | ||
| }; | ||
|
|
||
| const NewWindowOrIFrame: React.FC<Props> = props => { | ||
| const [iframeRef, setIframeRef] = useState<HTMLIFrameElement|null>(null); | ||
| const [loaded, setLoaded] = useState(false); | ||
|
|
||
| const doc = useDocument(props.mode, iframeRef, props.onClose); | ||
| useDocumentSetup(doc, setLoaded, props.onFocus); | ||
|
|
||
| useEffect(() => { | ||
| if (!doc) return; | ||
| doc.title = props.title; | ||
| }, [doc, props.title]); | ||
|
|
||
| useEffect(() => { | ||
| const win = doc?.defaultView; | ||
| if (win && 'electronWindow' in win && typeof win.electronWindow === 'object') { | ||
| const electronWindow = win.electronWindow as SecondaryWindowApi; | ||
| electronWindow.onSetWindowId(props.windowId); | ||
| } | ||
| }, [doc, props.windowId]); | ||
|
|
||
| const parentNode = loaded ? doc?.body : null; | ||
| const wrappedChildren = <WindowIdContext.Provider value={props.windowId}>{props.children}</WindowIdContext.Provider>; | ||
|
|
||
| // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Needed to allow adding the portal to the DOM | ||
| const contentPortal = parentNode && createPortal(wrappedChildren, parentNode) as any; | ||
| if (props.mode === WindowMode.NewWindow) { | ||
| return <div style={{ display: 'none' }}>{contentPortal}</div>; | ||
| } else { | ||
| return <iframe | ||
| ref={setIframeRef} | ||
| style={{ flexGrow: 1, width: '100%', height: '100%', border: 'none' }} | ||
| > | ||
| {contentPortal} | ||
| </iframe>; | ||
| } | ||
| }; | ||
|
|
||
| export default NewWindowOrIFrame; |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,125 @@ | ||
| import * as React from 'react'; | ||
| import { useCallback, useMemo, useRef, useState } from 'react'; | ||
| import NoteEditor from './NoteEditor'; | ||
| import StyleSheetContainer from '../StyleSheets/StyleSheetContainer'; | ||
| import { connect } from 'react-redux'; | ||
| import { AppState } from '../../app.reducer'; | ||
| import { Dispatch } from 'redux'; | ||
| import NewWindowOrIFrame, { WindowMode } from '../NewWindowOrIFrame'; | ||
| import WindowCommandsAndDialogs from '../WindowCommandsAndDialogs/WindowCommandsAndDialogs'; | ||
|
|
||
| const { StyleSheetManager } = require('styled-components'); | ||
| // Note: Transitive dependencies used only by react-select. Remove if react-select is removed. | ||
| import createCache from '@emotion/cache'; | ||
| import { CacheProvider } from '@emotion/react'; | ||
| import { stateUtils } from '@joplin/lib/reducer'; | ||
|
|
||
| interface Props { | ||
| dispatch: Dispatch; | ||
| themeId: number; | ||
|
|
||
| newWindow: boolean; | ||
| windowId: string; | ||
| activeWindowId: string; | ||
| } | ||
|
|
||
| const emptyCallback = () => {}; | ||
| const useWindowTitle = (isNewWindow: boolean) => { | ||
| const [title, setTitle] = useState('Untitled'); | ||
|
|
||
| if (!isNewWindow) { | ||
| return { | ||
| windowTitle: 'Editor', | ||
| onNoteTitleChange: emptyCallback, | ||
| }; | ||
| } | ||
|
|
||
| return { windowTitle: `Joplin - ${title}`, onNoteTitleChange: setTitle }; | ||
| }; | ||
|
|
||
| const SecondaryWindow: React.FC<Props> = props => { | ||
| const containerRef = useRef<HTMLDivElement>(null); | ||
|
|
||
| const { windowTitle, onNoteTitleChange } = useWindowTitle(props.newWindow); | ||
| const editor = <div className='note-editor-wrapper' ref={containerRef}> | ||
| <NoteEditor | ||
| windowId={props.windowId} | ||
| onTitleChange={onNoteTitleChange} | ||
| /> | ||
| </div>; | ||
|
|
||
| const newWindow = props.newWindow; | ||
| const onWindowClose = useCallback(() => { | ||
| if (newWindow) { | ||
| props.dispatch({ type: 'WINDOW_CLOSE', windowId: props.windowId }); | ||
| } | ||
| }, [props.dispatch, props.windowId, newWindow]); | ||
|
|
||
| const onWindowFocus = useCallback(() => { | ||
| // Verify that the window still has focus (e.g. to handle the case where the event was delayed). | ||
| if (containerRef.current?.ownerDocument.hasFocus()) { | ||
| props.dispatch({ | ||
| type: 'WINDOW_FOCUS', | ||
| windowId: props.windowId, | ||
| lastWindowId: props.activeWindowId, | ||
| }); | ||
| } | ||
| }, [props.dispatch, props.windowId, props.activeWindowId]); | ||
|
|
||
| return <NewWindowOrIFrame | ||
| mode={newWindow ? WindowMode.NewWindow : WindowMode.Iframe} | ||
| windowId={props.windowId} | ||
| onClose={onWindowClose} | ||
| onFocus={onWindowFocus} | ||
| title={windowTitle} | ||
| > | ||
| <LibraryStyleRoot> | ||
| <WindowCommandsAndDialogs windowId={props.windowId} /> | ||
| {editor} | ||
| </LibraryStyleRoot> | ||
| <StyleSheetContainer /> | ||
| </NewWindowOrIFrame>; | ||
| }; | ||
|
|
||
| interface StyleProviderProps { | ||
| children: React.ReactNode[]|React.ReactNode; | ||
| } | ||
|
|
||
| // Sets the root style container for libraries. At present, this is needed by react-select (which uses @emotion/...) | ||
| // and styled-components. | ||
| // See: https://github.com/JedWatson/react-select/issues/3680 and https://github.com/styled-components/styled-components/issues/659 | ||
| const LibraryStyleRoot: React.FC<StyleProviderProps> = props => { | ||
| const [dependencyStyleContainer, setDependencyStyleContainer] = useState<HTMLDivElement|null>(null); | ||
| const cache = useMemo(() => { | ||
| return createCache({ | ||
| key: 'new-window-cache', | ||
| container: dependencyStyleContainer, | ||
| }); | ||
| }, [dependencyStyleContainer]); | ||
|
|
||
| return <> | ||
| <div ref={setDependencyStyleContainer}></div> | ||
| <StyleSheetManager target={dependencyStyleContainer}> | ||
| <CacheProvider value={cache}> | ||
| {props.children} | ||
| </CacheProvider> | ||
| </StyleSheetManager> | ||
| </>; | ||
| }; | ||
|
|
||
| interface ConnectProps { | ||
| windowId: string; | ||
| } | ||
|
|
||
| export default connect((state: AppState, ownProps: ConnectProps) => { | ||
| // May be undefined if the window hasn't opened | ||
| const windowState = stateUtils.windowStateById(state, ownProps.windowId); | ||
|
|
||
| return { | ||
| themeId: state.settings.theme, | ||
| isSafeMode: state.settings.isSafeMode, | ||
| codeView: windowState?.editorCodeView ?? state.settings['editor.codeView'], | ||
| legacyMarkdown: state.settings['editor.legacyMarkdown'], | ||
| activeWindowId: stateUtils.activeWindowId(state), | ||
| }; | ||
| })(SecondaryWindow); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,8 @@ | ||
|
|
||
| .note-editor-wrapper { | ||
| display: flex; | ||
| flex-grow: 1; | ||
| flex-shrink: 1; | ||
| width: 100%; | ||
| height: 100%; | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,15 @@ | ||
| import { RefObject } from 'react'; | ||
|
|
||
| const getWindowCommandPriority = <T extends HTMLElement> (contentContainer: RefObject<T>) => { | ||
| if (!contentContainer.current) return 0; | ||
| const containerDocument = contentContainer.current.getRootNode() as Document; | ||
| if (!containerDocument || !containerDocument.hasFocus()) return 0; | ||
|
|
||
| if (contentContainer.current.contains(containerDocument.activeElement)) { | ||
| return 2; | ||
| } | ||
|
|
||
| // Container document has focus, but not this editor. | ||
| return 1; | ||
| }; | ||
| export default getWindowCommandPriority; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This allows the main window to be closed without quitting the app, even if "show tray icon" is disabled.