diff --git a/extension.json b/extension.json index 01f762e..a1baa34 100644 --- a/extension.json +++ b/extension.json @@ -2,13 +2,13 @@ "name": "IsekaiWidgets", "namemsg": "isekai-widgets", "author": "Hyperzlib", - "version": "1.0.3", + "version": "1.0.4", "url": "https://github.com/Isekai-Project/mediawiki-extension-IsekaiWidgets", "descriptionmsg": "isekai-widgets-desc", "license-name": "GPL-2.0-or-later", "type": "parserhook", "requires": { - "MediaWiki": ">= 1.35.0" + "MediaWiki": ">= 1.39.0" }, "MessagesDirs": { "IsekaiWidgets": [ @@ -101,6 +101,30 @@ "mobile" ] }, + "ext.isekai.previewPageList": { + "styles": [ + "previewPageList/ext.isekai.previewPageList.less" + ], + "packageFiles": [ + { "name": "isekaipreviewPageList", "file": "previewPageList/ext.isekai.previewPageList.js", "main": true }, + { "name": "PreviewPageList.vue", "file": "previewPageList/PreviewPageList.vue" } + ], + "es6": true, + "dependencies": [ + "oojs-ui-core", + "oojs-ui.styles.icons-movement", + "oojs-ui.styles.icons-interactions", + "vue" + ], + "targets": [ + "desktop", + "mobile" + ], + "messages": [ + "isekai-preview-page-list-preview-placeholder", + "isekai-preview-page-list-readmore-btn" + ] + }, "ext.isekai.information.infobox": { "styles": [ "information/ext.isekai.information.infobox.less" diff --git a/i18n/zh-hans.json b/i18n/zh-hans.json index f0514d3..745583a 100644 --- a/i18n/zh-hans.json +++ b/i18n/zh-hans.json @@ -34,5 +34,10 @@ "isekai-fab-hide-fab-button-success": "浮动按钮已隐藏,刷新页面可重新显示。", "isekai-offcanvastoc-menubutton": "目录", - "isekai-offcanvastoc-description-item": "简介" + "isekai-offcanvastoc-description-item": "简介", + + "isekai-preview-page-list-error-invalid-category": "未指定分类或分类名无效", + "isekai-preview-page-list-error-invalid-loader": "未知的页面列表加载器:\"$1\"", + "isekai-preview-page-list-preview-placeholder": "请选择一个页面进行查看", + "isekai-preview-page-list-readmore-btn": "查看" } \ No newline at end of file diff --git a/includes/PreviewPageListWidget.php b/includes/PreviewPageListWidget.php new file mode 100644 index 0000000..2a80581 --- /dev/null +++ b/includes/PreviewPageListWidget.php @@ -0,0 +1,71 @@ +getOutput()->addModules(['ext.isekai.previewPageList']); + + $loader = $params['loader'] ?? 'unknown'; + if ($loader === 'unknown' && !empty(trim($text))) { + $loader = 'pages'; + } + + $autoFocus = $params['autofocus'] ?? false; + if ($autoFocus === 'false' || $autoFocus === '0') { + $autoFocus = false; + } else { + $autoFocus = true; + } + + $lazyLoad = $params['lazyload'] ?? false; + if ($lazyLoad === 'false' || $lazyLoad === '0') { + $lazyLoad = false; + } else { + $lazyLoad = true; + } + + switch ($loader) { + case 'pages': + $pageList = explode("\n", trim($text)); + $pageList = array_map(function ($page) { + return trim($page); + }, $pageList); + + return Html::openElement('div', [ + 'class' => self::CONTAINER_CLASS_NAME, + 'data-loader' => $loader, + 'data-autofocus' => $autoFocus, + 'data-lazyload' => $lazyLoad, + ]) . Html::element('script', [ + 'type' => 'application/json', + 'data-pages' => true, + ], json_encode($pageList)) . Html::closeElement('div'); + case 'category': + $category = $params['category'] ?? null; + if (!$category) { + return Html::element('span', [ + 'class' => 'error' + ], wfMessage('isekai-preview-page-list-error-invalid-category')->parse()); + } + + $categoryTitle = new Title($category, NS_CATEGORY); + $categoryTitleText = $categoryTitle->getPrefixedText(); + + return Html::element('div', [ + 'class' => self::CONTAINER_CLASS_NAME, + 'data-category' => $categoryTitleText, + 'data-autofocus' => $autoFocus, + 'data-lazyload' => $lazyLoad, + ]); + } + + return Html::element('span', [ + 'class' => 'error' + ], wfMessage('isekai-preview-page-list-error-invalid-loader', $loader)->parse()); + } +} \ No newline at end of file diff --git a/includes/Widgets.php b/includes/Widgets.php index 9d46926..f34d691 100644 --- a/includes/Widgets.php +++ b/includes/Widgets.php @@ -18,6 +18,7 @@ class Widgets { $parser->setHook('discoverbox', [DiscoverWidget::class, 'create']); $parser->setHook('feedlist', [FeedListWidget::class, 'create']); $parser->setHook('previewcard', [PreviewCardWidget::class, 'create']); + $parser->setHook('previewpagelist', [PreviewPageListWidget::class, 'create']); $parser->setHook('buttonlink', [ButtonLinkWidget::class, 'create']); $parser->setHook('tile', [TileWidget::class, 'create']); diff --git a/modules/ext.isekai.widgets.global.less b/modules/ext.isekai.widgets.global.less index d3aa2d5..98b68f8 100644 --- a/modules/ext.isekai.widgets.global.less +++ b/modules/ext.isekai.widgets.global.less @@ -56,6 +56,7 @@ display: flex; align-items: center; justify-content: space-between; + gap: 0.8rem; border-top-left-radius: @isekai-card-border-radius; border-top-right-radius: @isekai-card-border-radius; @@ -64,6 +65,7 @@ .card-header-text { font-size: 1.25rem; + flex: 1 1; } @media(max-width: 360px){ @@ -132,9 +134,9 @@ background-color: rgba(0,0,0,.08); } - &:last-of-type { - border-bottom: none; - } + // &:last-of-type { + // border-bottom: none; + // } &.active { box-shadow: inset 4px 0 0 0 #3366cc; @@ -174,6 +176,11 @@ background-color: rgba(0, 0, 0, 0.1); } } + + .isekai-list-item-subtitle { + opacity: 0.6; + font-size: 0.9rem; + } .isekai-list-item-content { -webkit-box-flex: 1; diff --git a/modules/feedList/FeedList.vue b/modules/feedList/FeedList.vue index 41f3b2b..bd12caf 100644 --- a/modules/feedList/FeedList.vue +++ b/modules/feedList/FeedList.vue @@ -1,4 +1,6 @@ + + \ No newline at end of file diff --git a/modules/previewPageList/ext.isekai.previewPageList.js b/modules/previewPageList/ext.isekai.previewPageList.js new file mode 100644 index 0000000..93a704d --- /dev/null +++ b/modules/previewPageList/ext.isekai.previewPageList.js @@ -0,0 +1,174 @@ +const Vue = require("vue"); + +class BasePageListLoader { + loadPageList() { + return Promise.reject(new Error('Not implemented')); + } + + parseHTMLString(txt) { + try { + let parser = new DOMParser(); + let xmlDoc = parser.parseFromString(txt, "text/html"); + return xmlDoc; + } catch(e) { + console.error(e.message); + } + return null; + } + + loadPageContent(pageInfo) { + return new Promise((resolve, reject) => { + if (!pageInfo || !pageInfo.fullTitle) { + return reject(new Error('pageInfo.fullTitle is null')); + } + + let url = mw.util.getUrl(pageInfo.fullTitle); + if (url.indexOf('?') >= 0) { + url += '&'; + } else { + url += '?' + } + url += 'action=render'; + $.get(url, (str) => { + let $dom = $(this.parseHTMLString(str)); + let $content = $dom.find('.mw-parser-output'); + if ($content.length > 0) { + //删除目录 + $content.find('.toc').remove(); + resolve($content.html()); + } + }, 'html'); + }); + } + + getPageUrl(pageInfo) { + return new Promise((resolve, reject) => { + if (!pageInfo || !pageInfo.fullTitle) { + return reject(new Error('pageInfo.fullTitle is null')); + } + + let url = mw.util.getUrl(pageInfo.fullTitle); + resolve(url); + }); + } + + /** + * 根据标题生成页面信息 + * @param {string} title + * @returns + */ + makePageInfoFromTitle(title) { + let pageInfo = { + fullTitle: title, + title: title, + subTitle: null + }; + if (title.indexOf('/') !== 0) { + let splitPos = title.lastIndexOf('/'); + pageInfo.title = title.substring(splitPos + 1); + pageInfo.subTitle = title.substring(0, splitPos); + } + return pageInfo; + } +} + +class PresetPageListLoader extends BasePageListLoader { + /** + * 从页面列表加载页面 + * @param {string[]} pageList + */ + constructor(pageList) { + super(); + + this.pageList = pageList.map((pageTitle) => { + return this.makePageInfoFromTitle(pageTitle); + }); + } + + /** + * 加载页面列表 + * @returns {Promise<*>} + */ + loadPageList() { + return Promise.resolve(this.pageList); + } +} + +class CategoryPageListLoader extends BasePageListLoader { + /** + * 从分类加载页面 + * @param {string} category + */ + constructor(category) { + super(); + + this.category = category; + this.api = new mw.Api(); + } + + /** + * 加载页面列表 + * @returns {Promise<*>} + */ + loadPageList() { + return new Promise((resolve, reject) => { + this.api.get({ + action: 'query', + list: 'categorymembers', + cmtitle: this.category, + cmtype: page, + cmnamespace: 0, + cmlimit: 200, + cmsort: 'sortkey', + }).done((data) => { + if(data.query && data.query.categorymembers){ + let pageList = data.query.categorymembers.map((pageInfo) => { + return this.makePageInfoFromTitle(pageInfo.title); + }); + resolve(pageList); + } else if(data.error){ + reject(new Error(data.error.info)); + } else { + reject(new Error(mw.message('isekai-discover-error-cannotload').parse())); + } + }).fail((data) => { + reject(new Error(data.error.info)); + }); + }); + } +} + +let previewPageList = document.querySelectorAll('.isekai-preview-page-list-card'); +if (previewPageList.length > 0) { + const App = require("./PreviewPageList.vue"); + previewPageList.forEach((previewPageListDom) => { + try { + let props = {}; + let dataset = previewPageListDom.dataset; + + props.autoFocus = (dataset.autofocus == '1'); + props.lazyLoad = (dataset.lazyload == '1'); + + switch (dataset.loader) { + case 'pages': + let pagesJsonEl = document.querySelector('script[type="application/json"][data-pages]'); + let pageList = []; + if (pagesJsonEl) { + pageList = JSON.parse(pagesJsonEl.innerHTML); + } + props.pageListLoader = new PresetPageListLoader(pageList); + break; + case 'category': + let category = dataset.category; + props.pageListLoader = new CategoryPageListLoader(category); + break; + default: + console.error('Unknown loader: ' + dataset.loader); + } + + Vue.createMwApp(App, props).mount(previewPageListDom); + } catch (e) { + console.error(e); + } + }); +} \ No newline at end of file diff --git a/modules/previewPageList/ext.isekai.previewPageList.less b/modules/previewPageList/ext.isekai.previewPageList.less new file mode 100644 index 0000000..a0352f1 --- /dev/null +++ b/modules/previewPageList/ext.isekai.previewPageList.less @@ -0,0 +1,173 @@ +@preview-page-list-height: 70vh; + +.isekai-preview-page-list-card { + height: @preview-page-list-height; +} + +.isekai-preview-page-list-card > .card-header { + height: 2.2rem; +} + +.isekai-preview-page-list-container { + height: 100%; + margin: 0; + + .loading { + width: 100%; + height: 99.5%; + height: calc(100% - 2px); // fix: overflow because of border + margin-top: 1px; + display: flex; + + .spinner { + margin: auto; + padding: 2rem; + width: 100%; + display: flex; + justify-content: center; + + .oo-ui-progressBarWidget { + width: 100%; + } + } + } + + .isekai-booklet-layout { + display: flex; + height: @preview-page-list-height; + } + + // 页面列表 + .isekai-booklet-menu { + flex: 0 0; + width: 280px; + flex-basis: 280px; + padding-bottom: 0 !important; + height: @preview-page-list-height; + overflow-y: auto; + border-right: 1px solid rgba(0,0,0,.12); + } + + .isekai-preview-page-list { + width: 100%; + + .isekai-list-item { + &.active { + box-shadow: none; + background-color: #36c; + color: #fff !important; + } + } + } + + // 页面预览 + .isekai-booklet-content { + flex: 1 1; + width: 0; + height: @preview-page-list-height; + display: flex; + flex-direction: column; + + .card-header { + flex: 0 0; + } + + .back-button-container { + display: none; + } + + .isekai-preview-container { + box-sizing: border-box; + width: 100%; + flex: 1 1; + overflow-y: auto; + padding: 12px 20px; + } + + // 提示选择页面 + .isekai-preview-page-list-placeholder { + width: 100%; + height: @preview-page-list-height; + display: flex; + justify-content: center; + align-items: center; + font-size: 1.2rem; + } + } + + @media (max-width: 576px) { + .isekai-booklet-layout { + position: relative; + + &.animating { + .isekai-booklet-menu, + .isekai-booklet-content { + transition: transform 150ms ease-in-out, opacity 150ms ease-in-out; + position: absolute; + top: 0; + left: 0; + width: 100%; + } + + .isekai-booklet-menu { + display: block; + z-index: 3; + background: white; + } + + .isekai-booklet-content { + display: flex; + z-index: 2; + } + + &.focus-menu-transition { + .isekai-booklet-menu { + transform: scale(100%); + opacity: 1; + } + + .isekai-booklet-content { + transform: scale(80%); + opacity: 0; + } + } + + &.focus-content-transition { + .isekai-booklet-menu { + transform: scale(120%); + opacity: 0; + } + + .isekai-booklet-content { + transform: scale(100%); + opacity: 1; + } + } + } + + .isekai-booklet-menu { + width: 100%; + flex-basis: 100%; + border-right: none; + } + + .isekai-booklet-content { + display: none; + } + + &.focus-content { + .isekai-booklet-menu { + display: none; + } + + .isekai-booklet-content { + display: flex; + } + } + + .back-button-container { + display: flex; + } + } + } +} \ No newline at end of file