mirror of
https://github.com/yang991178/fluent-reader.git
synced 2025-04-24 23:18:46 +02:00
commit
5927bf3441
2
.github/workflows/release-linux.yml
vendored
2
.github/workflows/release-linux.yml
vendored
@ -25,6 +25,8 @@ jobs:
|
||||
- name: Get release
|
||||
id: get_release
|
||||
uses: bruceadams/get-release@v1.2.0
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Upload AppImage to release assets
|
||||
uses: actions/upload-release-asset@v1
|
||||
|
1
.gitignore
vendored
1
.gitignore
vendored
@ -3,6 +3,5 @@ dist/*.js
|
||||
dist/*.js.map
|
||||
dist/*.html
|
||||
bin/*
|
||||
package-lock.json
|
||||
.DS_Store
|
||||
*.provisionprofile
|
16
dist/styles/cards.css
vendored
16
dist/styles/cards.css
vendored
@ -1,4 +1,5 @@
|
||||
.info {
|
||||
display: flex;
|
||||
position: relative;
|
||||
margin: 10px 12px;
|
||||
line-height: 16px;
|
||||
@ -12,13 +13,20 @@
|
||||
font-size: 12px;
|
||||
vertical-align: top;
|
||||
display: inline-block;
|
||||
max-width: 144px;
|
||||
flex-grow: 1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.info span.creator {
|
||||
color: var(--neutralSecondaryAlt);
|
||||
}
|
||||
.info span.creator::before {
|
||||
display: inline-block;
|
||||
content: "/";
|
||||
margin: 0 5px;
|
||||
}
|
||||
.info span.time {
|
||||
float: right;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
@ -26,7 +34,6 @@
|
||||
display: block;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
float: right;
|
||||
text-align: center;
|
||||
}
|
||||
.read-indicator::after {
|
||||
@ -80,6 +87,9 @@
|
||||
left: 0;
|
||||
background: #0003;
|
||||
}
|
||||
.card span.h {
|
||||
background: #fce10080;
|
||||
}
|
||||
|
||||
.default-card {
|
||||
display: inline-block;
|
||||
|
7368
package-lock.json
generated
Normal file
7368
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "fluent-reader",
|
||||
"version": "0.6.2",
|
||||
"version": "0.6.3",
|
||||
"description": "A simplistic, modern desktop RSS reader",
|
||||
"main": "./dist/electron.js",
|
||||
"scripts": {
|
||||
|
@ -1,4 +1,5 @@
|
||||
import { ipcRenderer } from "electron"
|
||||
import { ImageCallbackTypes } from "../schema-types"
|
||||
|
||||
const utilsBridge = {
|
||||
platform: process.platform,
|
||||
@ -7,8 +8,8 @@ const utilsBridge = {
|
||||
return ipcRenderer.sendSync("get-version")
|
||||
},
|
||||
|
||||
openExternal: (url: string) => {
|
||||
ipcRenderer.invoke("open-external", url)
|
||||
openExternal: (url: string, background=false) => {
|
||||
ipcRenderer.invoke("open-external", url, background)
|
||||
},
|
||||
|
||||
showErrorBox: (title: string, content: string) => {
|
||||
@ -54,6 +55,9 @@ const utilsBridge = {
|
||||
callback(pos, text)
|
||||
})
|
||||
},
|
||||
imageCallback: (type: ImageCallbackTypes) => {
|
||||
ipcRenderer.invoke("image-callback", type)
|
||||
},
|
||||
|
||||
addWebviewKeydownListener: (callback: (event: Electron.Input) => any) => {
|
||||
ipcRenderer.removeAllListeners("webview-keydown")
|
||||
|
@ -19,6 +19,7 @@ type ArticleProps = {
|
||||
toggleStarred: (item: RSSItem) => void
|
||||
toggleHidden: (item: RSSItem) => void
|
||||
textMenu: (text: string, position: [number, number]) => void
|
||||
imageMenu: (position: [number, number]) => void
|
||||
dismissContextMenu: () => void
|
||||
}
|
||||
|
||||
@ -71,7 +72,7 @@ class Article extends React.Component<ArticleProps, ArticleState> {
|
||||
key: "openInBrowser",
|
||||
text: intl.get("openExternal"),
|
||||
iconProps: { iconName: "NavigateExternalInline" },
|
||||
onClick: this.openInBrowser
|
||||
onClick: e => { window.utils.openExternal(this.props.item.link, window.utils.platform === "darwin" ? e.metaKey : e.ctrlKey) }
|
||||
},
|
||||
{
|
||||
key: "copyURL",
|
||||
@ -95,7 +96,8 @@ class Article extends React.Component<ArticleProps, ArticleState> {
|
||||
|
||||
contextMenuHandler = (pos: [number, number], text: string) => {
|
||||
if (pos) {
|
||||
this.props.textMenu(text, pos)
|
||||
if (text) this.props.textMenu(text, pos)
|
||||
else this.props.imageMenu(pos)
|
||||
} else {
|
||||
this.props.dismissContextMenu()
|
||||
}
|
||||
@ -169,10 +171,6 @@ class Article extends React.Component<ArticleProps, ArticleState> {
|
||||
if (refocus) refocus.focus()
|
||||
}
|
||||
|
||||
openInBrowser = () => {
|
||||
window.utils.openExternal(this.props.item.link)
|
||||
}
|
||||
|
||||
toggleWebpage = () => {
|
||||
if (this.state.loadWebpage) {
|
||||
this.setState({loadWebpage: false})
|
||||
|
@ -7,18 +7,25 @@ export namespace Card {
|
||||
feedId: string
|
||||
item: RSSItem
|
||||
source: RSSSource
|
||||
keyword: string
|
||||
shortcuts: (item: RSSItem, key: string) => void
|
||||
markRead: (item: RSSItem) => void
|
||||
contextMenu: (feedId: string, item: RSSItem, e) => void
|
||||
showItem: (fid: string, item: RSSItem) => void
|
||||
}
|
||||
|
||||
export const openInBrowser = (props: Props) => {
|
||||
const openInBrowser = (props: Props, e: React.MouseEvent) => {
|
||||
props.markRead(props.item)
|
||||
window.utils.openExternal(props.item.link)
|
||||
window.utils.openExternal(props.item.link, window.utils.platform === "darwin" ? e.metaKey : e.ctrlKey)
|
||||
}
|
||||
|
||||
export const onClick = (props: Props, e: React.MouseEvent) => {
|
||||
export const bindEventsToProps = (props: Props) => ({
|
||||
onClick: (e: React.MouseEvent) => onClick(props, e),
|
||||
onMouseUp: (e: React.MouseEvent) => onMouseUp(props, e),
|
||||
onKeyDown: (e: React.KeyboardEvent) => onKeyDown(props, e),
|
||||
})
|
||||
|
||||
const onClick = (props: Props, e: React.MouseEvent) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
switch (props.source.openTarget) {
|
||||
@ -29,25 +36,25 @@ export namespace Card {
|
||||
break
|
||||
}
|
||||
case SourceOpenTarget.External: {
|
||||
openInBrowser(props)
|
||||
openInBrowser(props, e)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const onMouseUp = (props: Props, e: React.MouseEvent) => {
|
||||
const onMouseUp = (props: Props, e: React.MouseEvent) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
switch (e.button) {
|
||||
case 1:
|
||||
openInBrowser(props)
|
||||
openInBrowser(props, e)
|
||||
break
|
||||
case 2:
|
||||
props.contextMenu(props.feedId, props.item, e)
|
||||
}
|
||||
}
|
||||
|
||||
export const onKeyDown = (props: Props, e: React.KeyboardEvent) => {
|
||||
const onKeyDown = (props: Props, e: React.KeyboardEvent) => {
|
||||
props.shortcuts(props.item, e.key)
|
||||
}
|
||||
}
|
@ -2,6 +2,7 @@ import * as React from "react"
|
||||
import { Card } from "./card"
|
||||
import CardInfo from "./info"
|
||||
import Time from "../utils/time"
|
||||
import Highlights from "./highlights"
|
||||
|
||||
const className = (props: Card.Props) => {
|
||||
let cn = ["card", "compact-card"]
|
||||
@ -12,15 +13,13 @@ const className = (props: Card.Props) => {
|
||||
const CompactCard: React.FunctionComponent<Card.Props> = (props) => (
|
||||
<div
|
||||
className={className(props)}
|
||||
onClick={e => Card.onClick(props, e)}
|
||||
onMouseUp={e => Card.onMouseUp(props, e)}
|
||||
onKeyDown={e => Card.onKeyDown(props, e)}
|
||||
{...Card.bindEventsToProps(props)}
|
||||
data-iid={props.item._id}
|
||||
data-is-focusable>
|
||||
<CardInfo source={props.source} item={props.item} hideTime />
|
||||
<div className="data">
|
||||
<span className="title">{props.item.title}</span>
|
||||
<span className={"snippet" + (props.item.thumb ? "" : " show")}>{props.item.snippet.slice(0, 325)}</span>
|
||||
<span className="title"><Highlights text={props.item.title} keyword={props.keyword} title /></span>
|
||||
<span className="snippet"><Highlights text={props.item.snippet} keyword={props.keyword} /></span>
|
||||
</div>
|
||||
<Time date={props.item.date} />
|
||||
</div>
|
||||
|
@ -1,6 +1,7 @@
|
||||
import * as React from "react"
|
||||
import { Card } from "./card"
|
||||
import CardInfo from "./info"
|
||||
import Highlights from "./highlights"
|
||||
|
||||
const className = (props: Card.Props) => {
|
||||
let cn = ["card", "default-card"]
|
||||
@ -12,9 +13,7 @@ const className = (props: Card.Props) => {
|
||||
const DefaultCard: React.FunctionComponent<Card.Props> = (props) => (
|
||||
<div
|
||||
className={className(props)}
|
||||
onClick={e => Card.onClick(props, e)}
|
||||
onMouseUp={e => Card.onMouseUp(props, e)}
|
||||
onKeyDown={e => Card.onKeyDown(props, e)}
|
||||
{...Card.bindEventsToProps(props)}
|
||||
data-iid={props.item._id}
|
||||
data-is-focusable>
|
||||
{props.item.thumb ? (
|
||||
@ -25,8 +24,10 @@ const DefaultCard: React.FunctionComponent<Card.Props> = (props) => (
|
||||
<img className="head" src={props.item.thumb} />
|
||||
) : null}
|
||||
<CardInfo source={props.source} item={props.item} />
|
||||
<h3 className="title">{props.item.title}</h3>
|
||||
<p className={"snippet" + (props.item.thumb ? "" : " show")}>{props.item.snippet.slice(0, 325)}</p>
|
||||
<h3 className="title"><Highlights text={props.item.title} keyword={props.keyword} title /></h3>
|
||||
<p className={"snippet" + (props.item.thumb ? "" : " show")}>
|
||||
<Highlights text={props.item.snippet} keyword={props.keyword} />
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
|
||||
|
51
src/components/cards/highlights.tsx
Normal file
51
src/components/cards/highlights.tsx
Normal file
@ -0,0 +1,51 @@
|
||||
import * as React from "react"
|
||||
import { validateRegex } from "../../scripts/utils"
|
||||
|
||||
type HighlightsProps = {
|
||||
text: string
|
||||
keyword: string
|
||||
title?: boolean
|
||||
}
|
||||
|
||||
const Highlights: React.FunctionComponent<HighlightsProps> = (props) => {
|
||||
const spans: [string, boolean][] = new Array()
|
||||
let regex: RegExp
|
||||
if (props.keyword === "" || !(regex = validateRegex(props.keyword, "g"))) {
|
||||
if (props.title) spans.push([props.text, false])
|
||||
else spans.push([props.text.substr(0, 325), false])
|
||||
} else if (props.title) {
|
||||
let match: RegExpExecArray
|
||||
do {
|
||||
const startIndex = regex.lastIndex
|
||||
match = regex.exec(props.text)
|
||||
if (match) {
|
||||
if (startIndex != match.index) {
|
||||
spans.push([props.text.substring(startIndex, match.index), false])
|
||||
}
|
||||
spans.push([match[0], true])
|
||||
} else {
|
||||
spans.push([props.text.substr(startIndex), false])
|
||||
}
|
||||
} while (match && regex.lastIndex < props.text.length)
|
||||
} else {
|
||||
const match = regex.exec(props.text)
|
||||
if (match) {
|
||||
if (match.index != 0) {
|
||||
const startIndex = Math.max(match.index - 25, props.text.lastIndexOf(" ", Math.max(match.index - 10, 0)))
|
||||
spans.push([props.text.substring(Math.max(0, startIndex), match.index), false])
|
||||
}
|
||||
spans.push([match[0], true])
|
||||
if (regex.lastIndex < props.text.length) {
|
||||
spans.push([props.text.substr(regex.lastIndex, 300), false])
|
||||
}
|
||||
} else {
|
||||
spans.push([props.text.substr(0, 325), false])
|
||||
}
|
||||
}
|
||||
|
||||
return <>
|
||||
{spans.map(([text, flag]) => flag ? <span className="h">{text}</span> : text)}
|
||||
</>
|
||||
}
|
||||
|
||||
export default Highlights
|
@ -7,15 +7,21 @@ type CardInfoProps = {
|
||||
source: RSSSource
|
||||
item: RSSItem
|
||||
hideTime?: boolean
|
||||
showCreator?: boolean
|
||||
}
|
||||
|
||||
const CardInfo: React.FunctionComponent<CardInfoProps> = (props) => (
|
||||
<p className="info">
|
||||
{props.source.iconurl ? <img src={props.source.iconurl} /> : null}
|
||||
<span className="name">{props.source.name}</span>
|
||||
{props.hideTime ? null : <Time date={props.item.date} />}
|
||||
{props.item.hasRead ? null : <span className="read-indicator"></span>}
|
||||
<span className="name">
|
||||
{props.source.name}
|
||||
{props.showCreator && props.item.creator && (
|
||||
<span className="creator">{props.item.creator}</span>
|
||||
)}
|
||||
</span>
|
||||
{props.item.starred ? <span className="starred-indicator"></span> : null}
|
||||
{props.item.hasRead ? null : <span className="read-indicator"></span>}
|
||||
{props.hideTime ? null : <Time date={props.item.date} />}
|
||||
</p>
|
||||
)
|
||||
|
||||
|
@ -1,6 +1,7 @@
|
||||
import * as React from "react"
|
||||
import { Card } from "./card"
|
||||
import CardInfo from "./info"
|
||||
import Highlights from "./highlights"
|
||||
|
||||
const className = (props: Card.Props) => {
|
||||
let cn = ["card", "list-card"]
|
||||
@ -11,9 +12,7 @@ const className = (props: Card.Props) => {
|
||||
const ListCard: React.FunctionComponent<Card.Props> = (props) => (
|
||||
<div
|
||||
className={className(props)}
|
||||
onClick={e => Card.onClick(props, e)}
|
||||
onMouseUp={e => Card.onMouseUp(props, e)}
|
||||
onKeyDown={e => Card.onKeyDown(props, e)}
|
||||
{...Card.bindEventsToProps(props)}
|
||||
data-iid={props.item._id}
|
||||
data-is-focusable>
|
||||
{props.item.thumb ? (
|
||||
@ -21,7 +20,7 @@ const ListCard: React.FunctionComponent<Card.Props> = (props) => (
|
||||
) : null}
|
||||
<div className="data">
|
||||
<CardInfo source={props.source} item={props.item} />
|
||||
<h3 className="title">{props.item.title}</h3>
|
||||
<h3 className="title"><Highlights text={props.item.title} keyword={props.keyword} title /></h3>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
@ -1,6 +1,7 @@
|
||||
import * as React from "react"
|
||||
import { Card } from "./card"
|
||||
import CardInfo from "./info"
|
||||
import Highlights from "./highlights"
|
||||
|
||||
const className = (props: Card.Props) => {
|
||||
let cn = ["card", "magazine-card"]
|
||||
@ -12,9 +13,7 @@ const className = (props: Card.Props) => {
|
||||
const MagazineCard: React.FunctionComponent<Card.Props> = (props) => (
|
||||
<div
|
||||
className={className(props)}
|
||||
onClick={e => Card.onClick(props, e)}
|
||||
onMouseUp={e => Card.onMouseUp(props, e)}
|
||||
onKeyDown={e => Card.onKeyDown(props, e)}
|
||||
{...Card.bindEventsToProps(props)}
|
||||
data-iid={props.item._id}
|
||||
data-is-focusable>
|
||||
{props.item.thumb ? (
|
||||
@ -22,10 +21,10 @@ const MagazineCard: React.FunctionComponent<Card.Props> = (props) => (
|
||||
) : null}
|
||||
<div className="data">
|
||||
<div>
|
||||
<h3 className="title">{props.item.title}</h3>
|
||||
<p className={"snippet" + (props.item.thumb ? "" : " show")}>{props.item.snippet.slice(0, 325)}</p>
|
||||
<h3 className="title"><Highlights text={props.item.title} keyword={props.keyword} title /></h3>
|
||||
<p className="snippet"><Highlights text={props.item.snippet} keyword={props.keyword} /></p>
|
||||
</div>
|
||||
<CardInfo source={props.source} item={props.item} />
|
||||
<CardInfo source={props.source} item={props.item} showCreator />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
@ -6,7 +6,7 @@ import { ContextualMenu, IContextualMenuItem, ContextualMenuItemType, Directiona
|
||||
import { ContextMenuType } from "../scripts/models/app"
|
||||
import { RSSItem } from "../scripts/models/item"
|
||||
import { ContextReduxProps } from "../containers/context-menu-container"
|
||||
import { ViewType } from "../schema-types"
|
||||
import { ViewType, ImageCallbackTypes } from "../schema-types"
|
||||
import { FilterType } from "../scripts/models/feed"
|
||||
|
||||
export type ContextMenuProps = ContextReduxProps & {
|
||||
@ -75,9 +75,9 @@ export class ContextMenu extends React.Component<ContextMenuProps> {
|
||||
key: "openInBrowser",
|
||||
text: intl.get("openExternal"),
|
||||
iconProps: { iconName: "NavigateExternalInline" },
|
||||
onClick: () => {
|
||||
onClick: (e) => {
|
||||
this.props.markRead(this.props.item)
|
||||
window.utils.openExternal(this.props.item.link)
|
||||
window.utils.openExternal(this.props.item.link, window.utils.platform === "darwin" ? e.metaKey : e.ctrlKey)
|
||||
}
|
||||
},
|
||||
{
|
||||
@ -152,6 +152,38 @@ export class ContextMenu extends React.Component<ContextMenuProps> {
|
||||
},
|
||||
getSearchItem(this.props.text)
|
||||
]
|
||||
case ContextMenuType.Image: return [
|
||||
{
|
||||
key: "openInBrowser",
|
||||
text: intl.get("openExternal"),
|
||||
iconProps: { iconName: "NavigateExternalInline" },
|
||||
onClick: (e) => {
|
||||
if (window.utils.platform === "darwin" ? e.metaKey : e.ctrlKey) {
|
||||
window.utils.imageCallback(ImageCallbackTypes.OpenExternalBg)
|
||||
} else {
|
||||
window.utils.imageCallback(ImageCallbackTypes.OpenExternal)
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
key: "saveImageAs",
|
||||
text: intl.get("context.saveImageAs"),
|
||||
iconProps: { iconName: "SaveTemplate" },
|
||||
onClick: () => { window.utils.imageCallback(ImageCallbackTypes.SaveAs) }
|
||||
},
|
||||
{
|
||||
key: "copyImage",
|
||||
text: intl.get("context.copyImage"),
|
||||
iconProps: { iconName: "FileImage" },
|
||||
onClick: () => { window.utils.imageCallback(ImageCallbackTypes.Copy) }
|
||||
},
|
||||
{
|
||||
key: "copyImageURL",
|
||||
text: intl.get("context.copyImageURL"),
|
||||
iconProps: { iconName: "Link" },
|
||||
onClick: () => { window.utils.imageCallback(ImageCallbackTypes.CopyLink) }
|
||||
}
|
||||
]
|
||||
case ContextMenuType.View: return [
|
||||
{
|
||||
key: "section_1",
|
||||
|
@ -47,6 +47,7 @@ class CardsFeed extends React.Component<FeedProps> {
|
||||
key={item._id}
|
||||
item={item}
|
||||
source={this.props.sourceMap[item.source]}
|
||||
keyword={this.props.keyword}
|
||||
shortcuts={this.props.shortcuts}
|
||||
markRead={this.props.markRead}
|
||||
contextMenu={this.props.contextMenu}
|
||||
|
@ -11,6 +11,7 @@ export type FeedProps = FeedReduxProps & {
|
||||
viewType: ViewType
|
||||
items: RSSItem[]
|
||||
sourceMap: Object
|
||||
keyword: string
|
||||
shortcuts: (item: RSSItem, key: string) => void
|
||||
markRead: (item: RSSItem) => void
|
||||
contextMenu: (feedId: string, item: RSSItem, e) => void
|
||||
|
@ -16,6 +16,7 @@ class ListFeed extends React.Component<FeedProps> {
|
||||
key: item._id,
|
||||
item: item,
|
||||
source: this.props.sourceMap[item.source],
|
||||
keyword: this.props.keyword,
|
||||
shortcuts: this.props.shortcuts,
|
||||
markRead: this.props.markRead,
|
||||
contextMenu: this.props.contextMenu,
|
||||
|
@ -5,7 +5,7 @@ import { RSSItem, markUnread, markRead, toggleStarred, toggleHidden, itemShortcu
|
||||
import { AppDispatch } from "../scripts/utils"
|
||||
import { dismissItem, showOffsetItem } from "../scripts/models/page"
|
||||
import Article from "../components/article"
|
||||
import { openTextMenu, closeContextMenu } from "../scripts/models/app"
|
||||
import { openTextMenu, closeContextMenu, openImageMenu } from "../scripts/models/app"
|
||||
|
||||
type ArticleContainerProps = {
|
||||
itemId: string
|
||||
@ -35,6 +35,7 @@ const mapDispatchToProps = (dispatch: AppDispatch) => {
|
||||
toggleStarred: (item: RSSItem) => dispatch(toggleStarred(item)),
|
||||
toggleHidden: (item: RSSItem) => dispatch(toggleHidden(item)),
|
||||
textMenu: (text: string, position: [number, number]) => dispatch(openTextMenu(text, position)),
|
||||
imageMenu: (position: [number, number]) => dispatch(openImageMenu(position)),
|
||||
dismissContextMenu: () => dispatch(closeContextMenu())
|
||||
}
|
||||
}
|
||||
|
@ -38,6 +38,10 @@ const mapStateToProps = createSelector(
|
||||
event: context.event,
|
||||
sids: context.target
|
||||
}
|
||||
case ContextMenuType.Image: return {
|
||||
type: context.type,
|
||||
position: context.position
|
||||
}
|
||||
default: return { type: ContextMenuType.Hidden }
|
||||
}
|
||||
}
|
||||
|
@ -16,15 +16,17 @@ interface FeedContainerProps {
|
||||
const getSources = (state: RootState) => state.sources
|
||||
const getItems = (state: RootState) => state.items
|
||||
const getFeed = (state: RootState, props: FeedContainerProps) => state.feeds[props.feedId]
|
||||
const getKeyword = (state: RootState) => state.page.filter.search
|
||||
const getView = (_, props: FeedContainerProps) => props.viewType
|
||||
|
||||
const makeMapStateToProps = () => {
|
||||
return createSelector(
|
||||
[getSources, getItems, getFeed, getView],
|
||||
(sources, items, feed, viewType) => ({
|
||||
[getSources, getItems, getFeed, getView, getKeyword],
|
||||
(sources, items, feed, viewType, keyword) => ({
|
||||
feed: feed,
|
||||
items: feed.iids.map(iid => items[iid]),
|
||||
sourceMap: sources,
|
||||
keyword: keyword,
|
||||
viewType: viewType
|
||||
})
|
||||
)
|
||||
|
@ -3,7 +3,6 @@ import { ThemeSettings, SchemaTypes } from "./schema-types"
|
||||
import { store } from "./main/settings"
|
||||
import performUpdate from "./main/update-scripts"
|
||||
import { WindowManager } from "./main/window"
|
||||
import { openExternal } from "./main/utils"
|
||||
|
||||
if (!process.mas) {
|
||||
const locked = app.requestSingleInstanceLock()
|
||||
@ -85,14 +84,3 @@ ipcMain.handle("import-all-settings", (_, configs: SchemaTypes) => {
|
||||
winManager.mainWindow.close()
|
||||
}, process.platform === "darwin" ? 1000 : 0); // Why ???
|
||||
})
|
||||
|
||||
app.on("web-contents-created", (_, contents) => {
|
||||
contents.on("new-window", (event, url) => {
|
||||
if (winManager.hasWindow()) event.preventDefault()
|
||||
if (contents.getType() === "webview") openExternal(url)
|
||||
})
|
||||
contents.on("will-navigate", (event, url) => {
|
||||
event.preventDefault()
|
||||
if (contents.getType() === "webview") openExternal(url)
|
||||
})
|
||||
})
|
||||
|
@ -1,19 +1,41 @@
|
||||
import { ipcMain, shell, dialog, app, session, webContents, clipboard } from "electron"
|
||||
import { ipcMain, shell, dialog, app, session, clipboard } from "electron"
|
||||
import { WindowManager } from "./window"
|
||||
import fs = require("fs")
|
||||
|
||||
export function openExternal(url: string) {
|
||||
if (url.startsWith("https://") || url.startsWith("http://"))
|
||||
shell.openExternal(url)
|
||||
}
|
||||
import { ImageCallbackTypes } from "../schema-types"
|
||||
|
||||
export function setUtilsListeners(manager: WindowManager) {
|
||||
async function openExternal(url: string, background=false) {
|
||||
if (url.startsWith("https://") || url.startsWith("http://")) {
|
||||
if (background && process.platform === "darwin") {
|
||||
shell.openExternal(url, { activate: false })
|
||||
} else if (background && manager.hasWindow()) {
|
||||
manager.mainWindow.setAlwaysOnTop(true)
|
||||
await shell.openExternal(url)
|
||||
setTimeout(() => manager.mainWindow.setAlwaysOnTop(false), 1000)
|
||||
} else {
|
||||
shell.openExternal(url)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
app.on("web-contents-created", (_, contents) => {
|
||||
contents.on("new-window", (event, url, _, disposition) => {
|
||||
if (manager.hasWindow()) event.preventDefault()
|
||||
if (contents.getType() === "webview") openExternal(url, disposition === "background-tab")
|
||||
})
|
||||
contents.on("will-navigate", (event, url) => {
|
||||
event.preventDefault()
|
||||
if (contents.getType() === "webview") openExternal(url)
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
ipcMain.on("get-version", (event) => {
|
||||
event.returnValue = app.getVersion()
|
||||
})
|
||||
|
||||
ipcMain.handle("open-external", (_, url: string) => {
|
||||
openExternal(url)
|
||||
ipcMain.handle("open-external", (_, url: string, background: boolean) => {
|
||||
openExternal(url, background)
|
||||
})
|
||||
|
||||
ipcMain.handle("show-error-box", (_, title, content) => {
|
||||
@ -88,8 +110,30 @@ export function setUtilsListeners(manager: WindowManager) {
|
||||
}
|
||||
})
|
||||
contents.on("context-menu", (_, params) => {
|
||||
if (params.selectionText && manager.hasWindow()) {
|
||||
if ((params.hasImageContents || params.selectionText) && manager.hasWindow()) {
|
||||
if (params.hasImageContents) {
|
||||
ipcMain.removeHandler("image-callback")
|
||||
ipcMain.handleOnce("image-callback", (_, type: ImageCallbackTypes) => {
|
||||
switch (type) {
|
||||
case ImageCallbackTypes.OpenExternal:
|
||||
case ImageCallbackTypes.OpenExternalBg:
|
||||
openExternal(params.srcURL, type === ImageCallbackTypes.OpenExternalBg)
|
||||
break
|
||||
case ImageCallbackTypes.SaveAs:
|
||||
contents.session.downloadURL(params.srcURL)
|
||||
break
|
||||
case ImageCallbackTypes.Copy:
|
||||
contents.copyImageAt(params.x, params.y)
|
||||
break
|
||||
case ImageCallbackTypes.CopyLink:
|
||||
clipboard.writeText(params.srcURL)
|
||||
break
|
||||
}
|
||||
})
|
||||
manager.mainWindow.webContents.send("webview-context-menu", [params.x, params.y])
|
||||
} else {
|
||||
manager.mainWindow.webContents.send("webview-context-menu", [params.x, params.y], params.selectionText)
|
||||
}
|
||||
contents.executeJavaScript(`new Promise(resolve => {
|
||||
const dismiss = () => {
|
||||
document.removeEventListener("mousedown", dismiss)
|
||||
@ -142,6 +186,10 @@ export function setUtilsListeners(manager: WindowManager) {
|
||||
if (manager.hasWindow()) {
|
||||
const win = manager.mainWindow
|
||||
if (win.isMinimized()) win.restore()
|
||||
if (process.platform === "win32") {
|
||||
win.setAlwaysOnTop(true)
|
||||
win.setAlwaysOnTop(false)
|
||||
}
|
||||
win.focus()
|
||||
}
|
||||
})
|
||||
|
@ -32,6 +32,10 @@ export const enum SearchEngines {
|
||||
Google, Bing, Baidu, DuckDuckGo
|
||||
}
|
||||
|
||||
export const enum ImageCallbackTypes {
|
||||
OpenExternal, OpenExternalBg, SaveAs, Copy, CopyLink
|
||||
}
|
||||
|
||||
export type SchemaTypes = {
|
||||
version: string
|
||||
theme: ThemeSettings
|
||||
|
@ -83,7 +83,10 @@
|
||||
"starredOnly": "Starred only",
|
||||
"fullSearch": "Search in full text",
|
||||
"showHidden": "Show hidden articles",
|
||||
"manageSources": "Manage sources"
|
||||
"manageSources": "Manage sources",
|
||||
"saveImageAs": "Save image as …",
|
||||
"copyImage": "Copy image",
|
||||
"copyImageURL": "Copy image link"
|
||||
},
|
||||
"searchEngine": {
|
||||
"name": "Search engine",
|
||||
@ -113,6 +116,7 @@
|
||||
"errorParse": "An error has occurred when parsing the OPML file.",
|
||||
"errorParseHint": "Please ensure that the file isn't corrupted and is encoded with UTF-8.",
|
||||
"errorImport": "Error importing {count, plural, =1 {# source} other {# sources}}.",
|
||||
"exist": "This source already exists.",
|
||||
"opmlFile": "OPML File",
|
||||
"name": "Source name",
|
||||
"editName": "Edit name",
|
||||
|
@ -83,7 +83,10 @@
|
||||
"starredOnly": "仅星标文章",
|
||||
"fullSearch": "在正文中搜索",
|
||||
"showHidden": "显示隐藏文章",
|
||||
"manageSources": "管理订阅源"
|
||||
"manageSources": "管理订阅源",
|
||||
"saveImageAs": "将图像另存为",
|
||||
"copyImage": "复制图像",
|
||||
"copyImageURL": "复制图像链接"
|
||||
},
|
||||
"searchEngine": {
|
||||
"name": "搜索引擎",
|
||||
@ -111,6 +114,7 @@
|
||||
"errorParse": "解析OPML文件时出错",
|
||||
"errorParseHint": "请确保OPML文件完整且使用UTF-8编码。",
|
||||
"errorImport": "导入{count}项订阅源时出错",
|
||||
"exist": "该订阅源已存在",
|
||||
"opmlFile": "OPML文件",
|
||||
"name": "订阅源名称",
|
||||
"editName": "修改名称",
|
||||
|
@ -1,5 +1,5 @@
|
||||
import intl from "react-intl-universal"
|
||||
import { INIT_SOURCES, SourceActionTypes, ADD_SOURCE, UPDATE_SOURCE, DELETE_SOURCE, initSources } from "./source"
|
||||
import { INIT_SOURCES, SourceActionTypes, ADD_SOURCE, UPDATE_SOURCE, DELETE_SOURCE, initSources, SourceOpenTarget } from "./source"
|
||||
import { RSSItem, ItemActionTypes, FETCH_ITEMS, fetchItems } from "./item"
|
||||
import { ActionStatus, AppThunk, getWindowBreakpoint } from "../utils"
|
||||
import { INIT_FEEDS, FeedActionTypes, ALL, initFeeds } from "./feed"
|
||||
@ -10,7 +10,7 @@ import locales from "../i18n/_locales"
|
||||
import * as db from "../db"
|
||||
|
||||
export const enum ContextMenuType {
|
||||
Hidden, Item, Text, View, Group
|
||||
Hidden, Item, Text, View, Group, Image
|
||||
}
|
||||
|
||||
export const enum AppLogType {
|
||||
@ -74,6 +74,7 @@ export const OPEN_ITEM_MENU = "OPEN_ITEM_MENU"
|
||||
export const OPEN_TEXT_MENU = "OPEN_TEXT_MENU"
|
||||
export const OPEN_VIEW_MENU = "OPEN_VIEW_MENU"
|
||||
export const OPEN_GROUP_MENU = "OPEN_GROUP_MENU"
|
||||
export const OPEN_IMAGE_MENU = "OPEN_IMAGE_MENU"
|
||||
|
||||
interface CloseContextMenuAction {
|
||||
type: typeof CLOSE_CONTEXT_MENU
|
||||
@ -102,8 +103,13 @@ interface OpenGroupMenuAction {
|
||||
sids: number[]
|
||||
}
|
||||
|
||||
interface OpenImageMenuAction {
|
||||
type: typeof OPEN_IMAGE_MENU
|
||||
position: [number, number]
|
||||
}
|
||||
|
||||
export type ContextMenuActionTypes = CloseContextMenuAction | OpenItemMenuAction
|
||||
| OpenTextMenuAction | OpenViewMenuAction | OpenGroupMenuAction
|
||||
| OpenTextMenuAction | OpenViewMenuAction | OpenGroupMenuAction | OpenImageMenuAction
|
||||
|
||||
export const TOGGLE_LOGS = "TOGGLE_LOGS"
|
||||
export const PUSH_NOTIFICATION = "PUSH_NOTIFICATION"
|
||||
@ -163,6 +169,13 @@ export function openGroupMenu(sids: number[], event: React.MouseEvent): ContextM
|
||||
}
|
||||
}
|
||||
|
||||
export function openImageMenu(position: [number, number]): ContextMenuActionTypes {
|
||||
return {
|
||||
type: OPEN_IMAGE_MENU,
|
||||
position: position
|
||||
}
|
||||
}
|
||||
|
||||
export function toggleMenu(): AppThunk {
|
||||
return (dispatch, getState) => {
|
||||
dispatch({ type: TOGGLE_MENU })
|
||||
@ -213,14 +226,16 @@ export function setupAutoFetch(): AppThunk {
|
||||
|
||||
export function pushNotification(item: RSSItem): AppThunk {
|
||||
return (dispatch, getState) => {
|
||||
const state = getState()
|
||||
const sourceName = state.sources[item.source].name
|
||||
const sourceName = getState().sources[item.source].name
|
||||
if (!window.utils.isFocused()) {
|
||||
const options = { body: sourceName } as any
|
||||
if (item.thumb) options.icon = item.thumb
|
||||
const notification = new Notification(item.title, options)
|
||||
notification.onclick = () => {
|
||||
if (!getState().app.settings.display) {
|
||||
const state = getState()
|
||||
if (state.sources[item.source].openTarget === SourceOpenTarget.External) {
|
||||
window.utils.openExternal(item.link)
|
||||
} else if (!state.app.settings.display) {
|
||||
window.utils.focus()
|
||||
dispatch(showItemFromId(item._id))
|
||||
}
|
||||
@ -422,6 +437,13 @@ export function appReducer(
|
||||
target: action.sids
|
||||
}
|
||||
}
|
||||
case OPEN_IMAGE_MENU: return {
|
||||
...state,
|
||||
contextMenu: {
|
||||
type: ContextMenuType.Image,
|
||||
position: action.position
|
||||
}
|
||||
}
|
||||
case TOGGLE_MENU: return {
|
||||
...state,
|
||||
menu: !state.menu
|
||||
|
@ -204,29 +204,26 @@ export function addSourceFailure(err, batch: boolean): SourceActionTypes {
|
||||
}
|
||||
}
|
||||
|
||||
function insertSource(source: RSSSource, trials = 0): AppThunk<Promise<RSSSource>> {
|
||||
return (dispatch, getState) => {
|
||||
let insertPromises = Promise.resolve()
|
||||
function insertSource(source: RSSSource): AppThunk<Promise<RSSSource>> {
|
||||
return (_, getState) => {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (trials >= 25) {
|
||||
reject("Failed to insert the source into NeDB.")
|
||||
return
|
||||
}
|
||||
insertPromises = insertPromises.then(() => new Promise(innerResolve => {
|
||||
let sids = Object.values(getState().sources).map(s => s.sid)
|
||||
source.sid = Math.max(...sids, -1) + 1
|
||||
db.sdb.insert(source, (err, inserted) => {
|
||||
if (err) {
|
||||
if (/^Can't insert key [0-9]+,/.test(err.message)) {
|
||||
console.log("sid conflict")
|
||||
dispatch(insertSource(source, trials + 1))
|
||||
.then(inserted => resolve(inserted))
|
||||
.catch(err => reject(err))
|
||||
if ((new RegExp(`^Can't insert key ${source.url},`)).test(err.message)) {
|
||||
reject(intl.get("sources.exist"))
|
||||
} else {
|
||||
reject(err)
|
||||
}
|
||||
} else {
|
||||
resolve(inserted)
|
||||
}
|
||||
innerResolve()
|
||||
})
|
||||
}))
|
||||
})
|
||||
}
|
||||
|
||||
|
@ -47,7 +47,7 @@ export async function parseRSS(url: string) {
|
||||
throw new Error(intl.get("log.parseError"))
|
||||
}
|
||||
} else {
|
||||
throw new Error(result.statusText)
|
||||
throw new Error(result.status + " " + result.statusText)
|
||||
}
|
||||
}
|
||||
|
||||
@ -182,9 +182,9 @@ export function calculateItemSize(): Promise<number> {
|
||||
})
|
||||
}
|
||||
|
||||
export function validateRegex(regex: string): RegExp {
|
||||
export function validateRegex(regex: string, flags = ""): RegExp {
|
||||
try {
|
||||
return new RegExp(regex)
|
||||
return new RegExp(regex, flags)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
|
Loading…
x
Reference in New Issue
Block a user