CrazyDoctor 4 months ago
parent
commit
b37a8e130a

+ 1 - 1
package.json

@@ -1,6 +1,6 @@
 {
 	"name": "doczilla_js_docs",
-	"version": "1.0.6",
+	"version": "1.1.0",
 	"dependencies": {
 		"@types/sqlite3": "^3.1.11",
 		"@types/ws": "^8.5.10",

+ 4 - 0
src/comments/CommentsManager.ts

@@ -175,6 +175,10 @@ class CommentsManager {
 				if(propertiesList && !propertiesList.includes(propertyName))
 					continue;
 				const filePath = `${classDir}/${propertyName}`;
+
+				if(fs.lstatSync(filePath).isDirectory())
+					continue;
+
 				const [author, timestamp, ...rest] = fs.readFileSync(filePath, 'utf-8').split(':');
 				const text = rest.join(':');
 

+ 1 - 1
src/routes/GetClass.ts

@@ -24,7 +24,7 @@ class GetClass extends Route {
 		const className = req.params.className;
 		const cls: ClassMapEntity | null = Sources.findClass(className);
 		if(!cls) {
-			res.status(StatusCodes.NOT_FOUND).render('class', { isAdmin: session.isAdmin || false, isEditor: session.isEditor || false, Login: session.login || null, Class: className, ClassSource: '', template: 'class', title: 'Class not found', ClassList: Sources.getShortenedClassMap(), RepoNames: Sources.getRepoNames(), Z8Locales: null, Comments: null });
+			res.status(StatusCodes.NOT_FOUND).render('class', { isAdmin: session.isAdmin || false, isEditor: session.isEditor || false, Login: session.login || null, Class: { name: className, isPackage: false }, ClassSource: '', template: 'class', title: 'Class not found', ClassList: Sources.getShortenedClassMap(), RepoNames: Sources.getRepoNames(), Z8Locales: null, Comments: null });
 		} else {
 			CommentsManager.getCommentsByClass(cls.root, cls.name).then((result) => {
 				if(!result.success) {

+ 42 - 0
src/routes/GetPackage.ts

@@ -0,0 +1,42 @@
+import {HttpMethod, Route, StatusCodes} from 'org.crazydoctor.expressts';
+import {Request, Response} from 'express';
+import { PackageEntity, Sources } from '../sources/Sources';
+import ServerApp from '..';
+import { ISession } from '../session/ISession';
+import { CommentsManager } from '../comments/CommentsManager';
+
+class GetPackage extends Route {
+	protected action = (req: Request, res: Response): any => {
+		const session = req.session as ISession;
+		
+		if(ServerApp.SourcesUpdating) {
+			res.status(StatusCodes.ACCEPTED).render('sources-update');
+			return;
+		}
+
+		const packageName = req.params.packageName;
+		const root = req.params.root;
+		const pck: PackageEntity | null = Sources.findPackage(root, packageName);
+		if(!pck) {
+			res.status(StatusCodes.NOT_FOUND).render('class', { isAdmin: session.isAdmin || false, isEditor: session.isEditor || false, Login: session.login || null, Class: { name: packageName, isPackage: true }, ClassSource: '', template: 'class', title: 'Package not found', ClassList: Sources.getShortenedClassMap(), RepoNames: Sources.getRepoNames(), Z8Locales: null, Comments: null });
+		} else {
+			CommentsManager.getCommentsByClass(pck.root, pck.name).then((result) => {
+				if(!result.success) {
+					res.status(StatusCodes.INTERNAL_SERVER_ERROR).send('Internal server error');
+					return;
+				}
+
+				const commentsObj: {[key: string] : any} = {};
+				result.comments?.forEach((comment) => {
+					commentsObj[comment.propertyName] = comment;
+				});
+				res.status(StatusCodes.OK).render('class', { Class: pck, ClassSource: null, isAdmin: session.isAdmin || false, isEditor: session.isEditor || false, Login: session.login || null, template: 'class', title: `Package: ${packageName}`, ClassList: Sources.getShortenedClassMap(), RepoNames: Sources.getRepoNames(), Z8Locales: Sources.Z8Locales, Comments: commentsObj });
+			});
+		}
+	};
+	protected method = HttpMethod.GET;
+	protected order = 13;
+	protected route = '/package/:root/:packageName';
+}
+
+export default GetPackage;

+ 22 - 3
src/sources/Sources.ts

@@ -29,13 +29,20 @@ type ClassMapEntity = {
 	mixedIn: string[],
 	statics: Z8ClassProperties[],
 	properties: Z8ClassProperties[],
-	dynamicProperties: Z8ClassProperties[]
+	dynamicProperties: Z8ClassProperties[],
+	isPackage: false
 };
 
 type ClassMap = {
 	[key: string]: ClassMapEntity
 };
 
+type PackageEntity = {
+	name: string,
+	root: string,
+	isPackage: true
+};
+
 class Sources {
 
 	private static repoNames: string[]								= [];
@@ -117,6 +124,18 @@ class Sources {
 		return null;
 	}
 
+	public static findPackage(root: string, packageName: string): PackageEntity | null {
+		const regex = /^(?!.*\.\.)(?!\.)[a-zA-Zа-яА-Я0-9.]+(?<!\.)$/;
+		if(!regex.test(packageName))
+			return null;
+		const keys = Object.keys(Sources.ClassMap).filter((key) => {
+			return key.startsWith(packageName) && key !== packageName && Sources.ClassMap[key].root === root;
+		});
+		if(keys.length > 0)
+			return { name: packageName, root: root, isPackage: true };
+		return null;
+	}
+
 	public static update(callback?: () => any, silent?: boolean): void {
 		Sources.init();
 
@@ -222,7 +241,7 @@ class Sources {
 					};
 				}).filter(item1 => !dynamicProperties.some(item2 => item2.key === item1.key)));
 
-				Sources.ClassMap[define] = { name: define, root: pathPrefix, filePath: `${pathPrefix}/${filePath}`, children: [], extends: null, parentsBranch: [], mixins: [], mixedIn: [], properties: dynamicProperties, dynamicProperties: dynamicConfigProperties, statics: [] };
+				Sources.ClassMap[define] = { name: define, root: pathPrefix, filePath: `${pathPrefix}/${filePath}`, children: [], extends: null, parentsBranch: [], mixins: [], mixedIn: [], properties: dynamicProperties, dynamicProperties: dynamicConfigProperties, statics: [], isPackage: false };
 			}
 			fs.writeFileSync(Sources.dzAppPath, content + '\n', { flag: 'a+', encoding: 'utf8' });
 			progress && progress.next();
@@ -245,4 +264,4 @@ class Sources {
 	}
 }
 
-export { Sources, ClassMap, ClassMapEntity, Z8ClassProperties };
+export { Sources, ClassMap, ClassMapEntity, PackageEntity, Z8ClassProperties };

+ 72 - 44
src/views/class.pug

@@ -17,6 +17,7 @@ html
 		include imports/codemirror.javascript.import.pug
 		include imports/jquery.import.pug
 		include imports/global.import.pug
+		include modules/context-menu.module.pug
 		include modules/statistics.module.pug 
 		include imports/socket.import.pug
 		include imports/page.import.pug
@@ -25,49 +26,76 @@ html
 			div.left
 				include parts/left-header.part.pug
 			div.right
-				if(Class.name)
-					div.right-header
-						div.right-header-top
-							div.class-name
-								div.class-icon
-								span= Class.name
-								span= ' (Documented: '
-								span.class-documented-percentage= '0%'
-								span= ')'
-							div.display-mode-buttons
-								div.display-mode-button.mode-tabs(title='Display mode: Tabs', data-display-mode='mode-tabs')
-								div.display-mode-button.mode-list(title='Display mode: List', data-display-mode='mode-list')
-						div.tabs
-							div.tab.editor(data-tab='Editor')= 'Editor'
-							div.tab.methods(data-tab='Methods')= 'Methods'
-							div.tab.properties(data-tab='Properties')= 'Properties'
-							div.tab.parents(data-tab='Parents')= 'Parents'
-							div.tab.mixins(data-tab='Mixins')= 'Mixins'
-							div.tab.children(data-tab='Children')= 'Children'
-							div.tab.mixedin(data-tab='MixedIn')= 'Mixed in'
+				if(Class.isPackage)
+					if(Class.root)
+						div.right-header
+							div.right-header-top
+								div.class-name
+									div.package-icon
+									span= Class.name
+								div.display-mode-buttons
+									div.display-mode-button.mode-tabs(title='Display mode: Tabs', data-display-mode='mode-tabs')
+									div.display-mode-button.mode-list(title='Display mode: List', data-display-mode='mode-list')
+							div.tabs
+								div.tab.info(data-tab='Info')= 'Info'
+								if(isEditor)
+									div.tab.contribution(data-tab='Contribution')= 'Contribution history'
+						div.content
+							div.content-tab#info
+								h3.content-tab-title= 'Info'
 							if(isEditor)
-								div.tab.contribution(data-tab='Contribution')= 'Contribution history'
-					div.content
-						div.content-tab#parents
-							h3.content-tab-title= 'Parents'
-						div.content-tab#methods
-							h3.content-tab-title= 'Methods'
-						div.content-tab#properties
-							h3.content-tab-title= 'Properties'
-						div.content-tab#mixins
-							h3.content-tab-title= 'Mixins'
-						div.content-tab#mixedin
-							h3.content-tab-title= 'Mixed In'
-						div.content-tab#children
-							h3.content-tab-title= 'Children'
-						div.content-tab#editor
-							h3.content-tab-title= 'Editor'
-						if(isEditor)
-							div.content-tab#contribution
-								h3.content-tab-title= 'Contribution history'
+								div.content-tab#contribution
+									h3.content-tab-title= 'Contribution history'
+					else
+						div.right-header.class-not-found
+							div.class-not-found-image
+							div.class-not-found-text= 'Package `' + Class.name + '` not found'
 				else
-					div.right-header.class-not-found
-						div.class-not-found-image
-						div.class-not-found-text= 'Class `' + Class + '` not found'
-			div.context-menu.hidden
-			div.context-menu-overlay.hidden
+					if(Class.root)
+						div.right-header
+							div.right-header-top
+								div.class-name
+									div.class-icon
+									span= Class.name
+									span= ' (Documented: '
+									span.class-documented-percentage= '0%'
+									span= ')'
+								div.display-mode-buttons
+									div.display-mode-button.mode-tabs(title='Display mode: Tabs', data-display-mode='mode-tabs')
+									div.display-mode-button.mode-list(title='Display mode: List', data-display-mode='mode-list')
+							div.tabs
+								div.tab.editor(data-tab='Editor')= 'Editor'
+								div.tab.info(data-tab='Info')= 'Info'
+								div.tab.methods(data-tab='Methods')= 'Methods'
+								div.tab.properties(data-tab='Properties')= 'Properties'
+								div.tab.parents(data-tab='Parents')= 'Parents'
+								div.tab.mixins(data-tab='Mixins')= 'Mixins'
+								div.tab.children(data-tab='Children')= 'Children'
+								div.tab.mixedin(data-tab='MixedIn')= 'Mixed in'
+								if(isEditor)
+									div.tab.contribution(data-tab='Contribution')= 'Contribution history'
+						div.content
+							div.content-tab#info
+								h3.content-tab-title= 'Info'
+							div.content-tab#parents
+								h3.content-tab-title= 'Parents'
+							div.content-tab#methods
+								h3.content-tab-title= 'Methods'
+							div.content-tab#properties
+								h3.content-tab-title= 'Properties'
+							div.content-tab#mixins
+								h3.content-tab-title= 'Mixins'
+							div.content-tab#mixedin
+								h3.content-tab-title= 'Mixed In'
+							div.content-tab#children
+								h3.content-tab-title= 'Children'
+							div.content-tab#editor
+								h3.content-tab-title= 'Editor'
+							if(isEditor)
+								div.content-tab#contribution
+									h3.content-tab-title= 'Contribution history'
+					else
+						div.right-header.class-not-found
+							div.class-not-found-image
+							div.class-not-found-text= 'Class `' + Class.name + '` not found'
+			div.context-menu.hidden

+ 4 - 2
src/views/index.pug

@@ -13,11 +13,13 @@ html
 		include imports/cdclientlib.import.pug
 		include imports/codemirror.import.pug
 		include imports/jquery.import.pug
-		include imports/global.import.pug 
+		include imports/global.import.pug
+		include modules/context-menu.module.pug
 		include imports/page.import.pug
 	body
 		div.main
 			div.left
 				include parts/left-header.part.pug
 			div.right
-				div#editor
+				div#editor
+			div.context-menu.hidden

+ 3 - 0
src/views/modules/context-menu.module.pug

@@ -0,0 +1,3 @@
+script(src="/modules/context-menu/context-menu-item.js")
+script(src="/modules/context-menu/context-menu.js")
+link(rel="stylesheet", href="/modules/context-menu/context-menu.css")

+ 9 - 0
static/CDClientLib/CDClientLib.js

@@ -301,6 +301,10 @@ class Url {
 		return new Url().setHash(hash);
 	}
 
+	static setPath(path) {
+		return new Url().setPath(path);
+	}
+
 	static getOrigin() {
 		return new Url().getOrigin();
 	}
@@ -322,6 +326,11 @@ class Url {
 		return `${url.origin}${url.pathname}`;
 	}
 
+	setPath(path) {
+		this.url.pathname = path;
+		return this;
+	}
+
 	getHash() {
 		const hash = this.url.hash.substring(1);
 		return hash.length > 0 ? hash : null;

+ 120 - 18
static/modules/class-list/class-list.js

@@ -61,7 +61,7 @@ class ClassListModule {
 						'data-class-shortname': classList[className].shortName,
 						'title': className
 					}
-				}, ClassListModule.classListElement).on('click', ClassListModule.onListItemClick);
+				}, ClassListModule.classListElement).on('mousedown', ClassListModule.onListItemMouseDown);
 			}
 		}
 	}
@@ -72,10 +72,10 @@ class ClassListModule {
 
 		const dirCollapsedIconEl = DOM.create({ tag: 'div', cls: 'dir-collapsed-icon' });
 		const dirIconEl = DOM.create({ tag: 'div', cls: 'dir-icon' });
-		const dirNameEl = DOM.create({ tag: 'div', cls: 'dir-name', cn: [dirCollapsedIconEl, dirIconEl], innerHTML: dirName }).on('click', ClassListModule.onListItemClick);
+		const dirNameEl = DOM.create({ tag: 'div', cls: 'dir-name', cn: [dirCollapsedIconEl, dirIconEl], innerHTML: dirName }).on('mousedown', ClassListModule.onListItemMouseDown);
 		const dirContentEl = DOM.create({ tag: 'div', cls: 'dir-content' });
 		
-		const dirItem = DOM.create({ tag: 'div', cls: 'dir-item', cn: [dirNameEl, dirContentEl], attr: { 'data-dir-path': dirPath } }, parentContainer);
+		const dirItem = DOM.create({ tag: 'div', cls: `dir-item ${ Class && Class.isPackage ? (Class.root + '/' + Class.name.replaceAll('.', '/') === dirPath ? 'selected' : '') : '' }`, cn: [dirNameEl, dirContentEl], attr: { 'data-dir-path': dirPath } }, parentContainer);
 
 		const opened = (DOM.getCookieProperty(App.CookieName, ClassListModule.OpenedFoldersCookieName) || []).indexOf(dirPath) > -1;
 
@@ -108,7 +108,7 @@ class ClassListModule {
 				'data-class-shortname': classShortName,
 				'title': className
 			}
-		}, container).on('click', ClassListModule.onListItemClick);
+		}, container).on('mousedown', ClassListModule.onListItemMouseDown);
 	}
 
 	static transformStructure(obj) {
@@ -170,25 +170,69 @@ class ClassListModule {
 			target = target.getParent();
 
 		if(target.hasClass('class-item')) {
-			Url.goTo(`/class/${target.getAttribute('data-class-name')}`);
+			ClassListModule.goToClass(target);
 		} else if(target.hasClass('dir-name')) { 
-			const parent = target.getParent();
-			const dataDirPath = parent.getAttribute('data-dir-path');
-			parent.switchClass('collapsed');
+			ClassListModule.onDirClick(target);
+		}
+	}
 
-			if(!CDUtils.isEmpty(ClassListModule.SearchQuery))
-				return;
+	static onDirClick(target) {
+		const parent = target.getParent();
+		const dataDirPath = parent.getAttribute('data-dir-path');
+		parent.switchClass('collapsed');
 
-			let openedFoldersCookieValue = DOM.getCookieProperty(App.CookieName, ClassListModule.OpenedFoldersCookieName) || [];
+		if(!CDUtils.isEmpty(ClassListModule.SearchQuery))
+			return;
 
-			if(parent.hasClass('collapsed')) {
-				openedFoldersCookieValue.splice(openedFoldersCookieValue.indexOf(dataDirPath), 1);
-			} else {
-				openedFoldersCookieValue.push(dataDirPath);
-			}
+		let openedFoldersCookieValue = DOM.getCookieProperty(App.CookieName, ClassListModule.OpenedFoldersCookieName) || [];
+
+		if(parent.hasClass('collapsed')) {
+			openedFoldersCookieValue.splice(openedFoldersCookieValue.indexOf(dataDirPath), 1);
+		} else {
+			openedFoldersCookieValue.push(dataDirPath);
+		}
+
+		DOM.setCookieProperty(App.CookieName, ClassListModule.OpenedFoldersCookieName, openedFoldersCookieValue, 24);
+	}
+
+	static getDirPath(target) {
+		return CDElement.get(target).getAttribute('data-dir-path');
+	}
+
+	static getClassName(target) {
+		return CDElement.get(target).getAttribute('data-class-name');
+	}
+
+	static getPackageName(target) {
+		const path = ClassListModule.getPackagePath(target).split('/');
+		return path[path.length - 1];
+	}
 
-			DOM.setCookieProperty(App.CookieName, ClassListModule.OpenedFoldersCookieName, openedFoldersCookieValue, 24);
+	static getClassPath(target) {
+		return `/class/${ClassListModule.getClassName(target)}`;
+	}
+
+	static getPackagePath(target) {
+		const replaceSlashes = (str) => {
+			let firstSlash = str.indexOf('/');
+			if (firstSlash === -1) {
+					return str;
+			}
+			let beforeFirstSlash = str.slice(0, firstSlash + 1);
+			let afterFirstSlash = str.slice(firstSlash + 1).replace(/\//g, '.');
+			return beforeFirstSlash + afterFirstSlash;
 		}
+
+		const dirPath = replaceSlashes(ClassListModule.getDirPath(target));
+		return `/package/${dirPath}`;
+	}
+
+	static goToClass(target, newTab) {
+		Url.goTo(ClassListModule.getClassPath(target), newTab);
+	}
+
+	static goToPackage(target, newTab) {
+		Url.goTo(ClassListModule.getPackagePath(target), newTab);
 	}
 
 	static onModeButtonClick(e) {
@@ -204,7 +248,7 @@ class ClassListModule {
 	static clear() {
 		DOM.get('.class-list').getChildrenRecursive().forEach((item) => {
 			if(item.hasClass('class-item') || item.hasClass('dir-name'))
-				item.un('click', ClassListModule.onListItemClick);
+				item.un('mousedown', ClassListModule.onListItemMouseDown)
 			item.remove();
 		});
 	}
@@ -220,6 +264,64 @@ class ClassListModule {
 		});
 	}
 
+	static onListItemMouseDown(e) {
+		const element = CDElement.get(e.target);
+
+		if(e.buttons === DOM.MouseButtons.Left) {
+			ClassListModule.onListItemClick(e);
+		} else if(e.buttons === DOM.MouseButtons.Right) {
+			setTimeout(() => {
+				ClassListModule.showContextMenu(element, { x: e.pageX, y: e.pageY });
+			}, 10);
+		}
+	}
+
+	static showContextMenu(target, pos) {
+		while(!target.hasClass('class-item') && !target.hasClass('dir-name'))
+			target = target.getParent();
+
+		const contextMenu = ContextMenu.init();
+
+		if(target.hasClass('class-item')) {
+			contextMenu.addItem('Open', 'Open', () => {
+				ClassListModule.goToClass(target, false);
+			});
+
+			contextMenu.addItem('OpenInNewTab', 'Open in a new tab', () => {
+				ClassListModule.goToClass(target, true);
+			});
+
+			contextMenu.addDelimiter();
+
+			contextMenu.addItem('CopyLink', 'Copy link', () => {
+				DOM.copyToClipboard(Url.setPath(ClassListModule.getClassPath(target)).setHash('').toString());
+			});
+
+			contextMenu.addItem('CopyHtmlLink', 'Copy HTML link', () => {
+				DOM.copyToClipboard(`<a href="${ClassListModule.getClassPath(target)}">${ClassListModule.getClassName(target)}</a>`);
+			});
+		} else if(target.hasClass('dir-name') && !target.getParent().getParent().hasClass('class-list')) {
+			contextMenu.addItem('Open', 'Open', () => {
+				ClassListModule.goToPackage(target.getParent(), false);
+			});
+
+			contextMenu.addItem('OpenInNewTab', 'Open in a new tab', () => {
+				ClassListModule.goToPackage(target.getParent(), true);
+			});
+
+			contextMenu.addDelimiter();
+
+			contextMenu.addItem('CopyLink', 'Copy link', () => {
+				DOM.copyToClipboard(Url.setPath(ClassListModule.getPackagePath(target.getParent())).setHash('').toString());
+			});
+
+			contextMenu.addItem('CopyHtmlLink', 'Copy HTML link', () => {
+				DOM.copyToClipboard(`<a href="${ClassListModule.getPackagePath(target.getParent())}">${ClassListModule.getPackageName(target.getParent())}</a>`);
+			});
+		}
+		contextMenu.show(pos);
+	}
+
 	static init() {
 		DOM.get('.class-list-mode-button.structurized').on(DOM.Events.Click, ClassListModule.onModeButtonClick);
 		DOM.get('.class-list-mode-button.unstructurized').on(DOM.Events.Click, ClassListModule.onModeButtonClick);

+ 35 - 0
static/modules/context-menu/context-menu-item.js

@@ -0,0 +1,35 @@
+class ContextMenuItem {
+	constructor(name, text, action, menuDom) {
+		this.name = name;
+		this.text = text;
+		this.action = action;
+		this.menuDom = menuDom;
+	}
+
+	render() {
+		this.dom = DOM.create({ tag: DOM.Tags.Div, cls: 'context-menu-item', innerHTML: this.getText(), attr: { 'data-context-menu-item-name': this.getName() } }, this.menuDom)
+			.on(DOM.Events.Click, this.getAction());
+		return this;
+	}
+
+	dispose() {
+		this.getDom().un(DOM.Events.Click, this.getAction());
+		return this;
+	}
+
+	getDom() {
+		return this.dom;
+	}
+
+	getText() {
+		return this.text;
+	}
+
+	getName() {
+		return this.name;
+	}
+
+	getAction() {
+		return this.action;
+	}
+}

+ 29 - 0
static/modules/context-menu/context-menu.css

@@ -0,0 +1,29 @@
+.context-menu {
+	position: absolute;
+	display: flex;
+	flex-direction: column;
+	z-index: 10000;
+	width: fit-content;
+	background-color: #312d2a;
+	border: 1px solid #d1d1d1;
+	box-shadow: 3px 3px 3px #0004;
+}
+
+.context-menu > .context-menu-item {
+	padding: 5px 10px;
+	user-select: none;
+	cursor: pointer;
+}
+
+.context-menu > .context-menu-item {
+	margin: 3px 0;
+}
+
+.context-menu > .context-menu-item:hover {
+	background-color: #ffffff40;
+}
+
+.context-menu > .context-menu-delimiter {
+	border-bottom: 1px solid #d1d1d1;
+	width: 100%;
+}

+ 83 - 0
static/modules/context-menu/context-menu.js

@@ -0,0 +1,83 @@
+class ContextMenu {
+	static Type = {
+		PropertyItem: 'property-item',
+		ClassListItem: 'class-list-item'
+	};
+
+	static init() {
+		if(!DOM.get('.context-menu'))
+			return null;
+
+		if(this.instance)
+			return this.instance;
+		return this.instance = new ContextMenu();
+	}
+
+	constructor() {
+		this.dom = DOM.get('.context-menu');
+		this.items = {};
+	}
+
+	show(pos) {
+		this.dom.style('left', `${pos.x}px`).style('top', `${pos.y}px`);
+		this.dom.removeClass('hidden');
+	}
+
+	addDelimiter() {
+		DOM.create({ tag: DOM.Tags.Div, cls: 'context-menu-delimiter' }, this.dom);
+		return this;
+	}
+
+	addItem(name, text, action) {
+		const itemAction = (e) => {
+			action(e);
+			this.hide();
+		};
+
+		this.items[name] = new ContextMenuItem(name, text, itemAction, this.dom).render();
+
+		return this;
+	}
+
+	clear() {
+		for(const child of this.dom.getChildren()) {
+			const name = child.getAttribute('data-context-menu-item-name');
+			if(name) {
+				this.items[name].dispose();
+				this.items[name] = null;
+				delete this.items[name];
+			}
+			child.remove();
+		}
+
+		return this;
+	}
+
+	hide() {
+		if(this.dom.hasClass('hidden'))
+			return this;
+
+		this.dom.addClass('hidden');
+		this.clear();
+
+		return this;
+	}
+}
+
+window_.on(DOM.Events.MouseDown, (e) => {
+	const contextMenu = ContextMenu.init();
+
+	if(!contextMenu)
+		return;
+
+	let target = CDElement.get(e.target);
+
+	while(target != null && !target.hasClass('context-menu')) {
+		target = target.getParent();
+	}
+
+	if(target != null && target.hasClass('context-menu'))
+		return;
+
+	contextMenu.hide();
+});

+ 235 - 252
static/page/class/script.js

@@ -63,6 +63,7 @@ class ClassPage {
 		Mixins: 'Mixins',
 		Children: 'Children',
 		MixedIn: 'MixedIn',
+		Info: 'Info',
 		Contribution: 'Contribution'
 	};
 
@@ -71,7 +72,8 @@ class ClassPage {
 		Base: 'base',
 		Overridden: 'overridden',
 		Dynamic: 'dynamic',
-		Inherited: 'inherited'
+		Inherited: 'inherited',
+		ClassComment: 'ClassComment'
 	};
 
 	static PropertyLabel = {
@@ -104,14 +106,11 @@ class ClassPage {
 		ShortName: 'shortName'
 	};
 
-	static ContextMenuType = {
-		PropertyItem: 'property-item'
-	};
-
 	static __static__ = '__static__';
+	static __self__ = '__self__';
 
 	start() {
-		if(typeof Class === 'string') {
+		if(!Class.root) {
 			return this;
 		}
 
@@ -122,7 +121,8 @@ class ClassPage {
 			[ClassPage.TabNames.Properties]:		DOM.get('.tab.properties'),
 			[ClassPage.TabNames.Mixins]:				DOM.get('.tab.mixins'),
 			[ClassPage.TabNames.Children]:			DOM.get('.tab.children'),
-			[ClassPage.TabNames.MixedIn]:				DOM.get('.tab.mixedin')
+			[ClassPage.TabNames.MixedIn]:				DOM.get('.tab.mixedin'),
+			[ClassPage.TabNames.Info]:					DOM.get('.tab.info')
 		};
 		
 		this.contentElements = {
@@ -132,7 +132,8 @@ class ClassPage {
 			[ClassPage.TabNames.Properties]:		DOM.get('.content-tab#properties'),
 			[ClassPage.TabNames.Mixins]:				DOM.get('.content-tab#mixins'),
 			[ClassPage.TabNames.Children]:			DOM.get('.content-tab#children'),
-			[ClassPage.TabNames.MixedIn]:				DOM.get('.content-tab#mixedin')
+			[ClassPage.TabNames.MixedIn]:				DOM.get('.content-tab#mixedin'),
+			[ClassPage.TabNames.Info]:					DOM.get('.content-tab#info')
 		};
 
 		if(isEditor) {
@@ -159,10 +160,7 @@ class ClassPage {
 		const tabsModeButton = this.tabsModeButton = DOM.get('.display-mode-button.mode-tabs');
 		const listModeButton = this.listModeButton = DOM.get('.display-mode-button.mode-list');
 
-		/* >>> Context menu */
-		this.contextMenu = DOM.get('.context-menu');
-		this.contextMenuItems = {};
-		/* <<< Context menu */
+		this.contextMenu = ContextMenu.init();
 
 		(mode === ClassPage.Mode.Tabs ? tabsModeButton : listModeButton).addClass(ClassPage.StyleClasses.Selected);
 
@@ -189,7 +187,8 @@ class ClassPage {
 
 		this.rightContainer.addClass(mode);
 
-		this.codeMirrorEditor.cmRefresh();
+		if(!Class.isPackage)
+			this.codeMirrorEditor.cmRefresh();
 	}
 
 	selectTab(tab) {
@@ -224,7 +223,8 @@ class ClassPage {
 	registerTabsEventListeners() {
 		const tabElements = this.tabElements;
 		for(const tabName of Object.keys(tabElements)) {
-			tabElements[tabName].on(DOM.Events.Click, this.onTabClick.bind(this));
+			if(tabElements[tabName])
+				tabElements[tabName].on(DOM.Events.Click, this.onTabClick.bind(this));
 		}
 	}
 
@@ -247,21 +247,26 @@ class ClassPage {
 	}
 
 	renderContent() {
-		this.renderEditor();
-		this.renderParents();
-		this.renderMixins();
-		this.renderChildren();
-		this.renderMixedIn();
-		this.renderProperties();
-		this.renderMethods();
-		this.renderDocumentedPercentage();
-		this.loadInheritedComments();
+		if(!Class.isPackage) {
+			this.renderEditor();
+			this.renderParents();
+			this.renderMixins();
+			this.renderChildren();
+			this.renderMixedIn();
+			this.renderProperties();
+			this.renderMethods();
+			this.renderDocumentedPercentage();
+			this.loadInheritedComments();
+		}
 
+		this.renderInfo();
 		if(isEditor)
 			this.renderContribution();
 	}
 
 	renderDocumentedPercentage() {
+		if(Class.isPackage)
+			return;
 		this.documentedPercentage.setInnerHTML(`${Math.round(this.documented/this.documentable * 10000) / 100}%`);
 	}
 
@@ -354,15 +359,15 @@ class ClassPage {
 		}
 
 		for(const property of properties) {
-			const key = isStatics ? `${ClassPage.__static__}${property.key}` : property.key;
-			const propertyItem = DOM.create({ tag: DOM.Tags.Div, cls: 'property-item', attr: { [ClassPage.Attributes.DataPropertyName]: key, [ClassPage.Attributes.DataPropertyType]: property.type }}, propertiesList).on(DOM.Events.MouseDown, (e) => {
+			const onMouseDown = (e) => {
 				const element = CDElement.get(e.target);
+				const key = isStatics ? `${ClassPage.__static__}${property.key}` : property.key;
 
 				if(e.buttons === DOM.MouseButtons.Right) {
 					if(element.hasClass('property-item-saving-filler'))
 						return;
 					setTimeout(() => {
-						this.showContextMenu(ClassPage.ContextMenuType.PropertyItem, element, { x: e.pageX, y: e.pageY });
+						this.showContextMenu(element, { x: e.pageX, y: e.pageY });
 					}, 10);
 				} else if (e.buttons === DOM.MouseButtons.Left) {					
 					if(propertyItemClickable(element))
@@ -373,142 +378,9 @@ class ClassPage {
 						this.searchPropertyInEditor(isMethods, property.dynamic, key);
 					}
 				}
-			});
-
-			const itemNameText = DOM.create({ tag: DOM.Tags.Span, innerHTML: isMethods ? 'Signature: ' : 'Name: ' });
-			const itemNameValue = DOM.create({ tag: DOM.Tags.Span, cls: 'property-item-name-span', innerHTML: isMethods ? property.value : property.key });
-			DOM.create({ tag: DOM.Tags.Div, cls: 'property-item-name', cn: [itemNameText, itemNameValue] }, propertyItem);
-			
-			if(type !== ClassPage.PropertyType.Dynamic && !isMethods) {
-				const itemTypeText = DOM.create({ tag: DOM.Tags.Span, innerHTML: 'Type: ' });
-				const itemTypeValue = DOM.create({ tag: DOM.Tags.Span, cls: 'property-item-type-span', innerHTML: property.type });
-				DOM.create({ tag: DOM.Tags.Div, cls: 'property-item-type', cn: [itemTypeText, itemTypeValue] }, propertyItem);
-			}
-
-			if(type !== ClassPage.PropertyType.Dynamic && !isMethods && property.type !== 'undefined') {
-				const itemValueText = DOM.create({ tag: DOM.Tags.Span, innerHTML: 'Default value: ' });
-				const itemValueValue = DOM.create({ tag: DOM.Tags.Span, cls: 'property-item-default-value-span', innerHTML: property.type === 'string' ? `'${property.value}'` : property.value });
-				DOM.create({ tag: DOM.Tags.Div, cls: 'property-item-default-value', cn: [itemValueText, itemValueValue] }, propertyItem);
-			}
-
-			if(type !== ClassPage.PropertyType.Dynamic && type !== ClassPage.PropertyType.Statics && type !== ClassPage.PropertyType.Base) {
-				const itemParentText = DOM.create({ tag: DOM.Tags.Span, innerHTML: 'Nearest parent: ' });
-				const itemParentValue = DOM.create({ tag: DOM.Tags.Span, cls: 'property-item-nearest-parent-span', innerHTML: property.nearestParent }).on('click', (e) => {
-					Url.goTo(`/class/${property.nearestParent}`);
-				});
-				DOM.create({ tag: DOM.Tags.Div, cls: 'property-item-nearest-parent', cn: [itemParentText, itemParentValue] }, propertyItem);
-				propertyItem.setAttribute(ClassPage.Attributes.DataPropertyParent, property.nearestParent);
-			}
-
-			if(type === ClassPage.PropertyType.Dynamic) {
-				propertyItem.setAttribute(ClassPage.Attributes.DataPropertyDynamic, 'true');
-			}
-
-			const itemCommentText = DOM.create({ tag: DOM.Tags.Div, innerHTML: 'Comment:' });
-			const itemCommentCn = [itemCommentText];
-
-			const loadedComment = Comments[type === ClassPage.PropertyType.Statics ? `${ClassPage.__static__}${property.key}` : property.key];
-			const loadedCommentText = loadedComment && typeof loadedComment === 'object' ? loadedComment.text : '';
-
-			const hasComment = loadedComment && typeof loadedComment === 'object' && loadedCommentText.length > 0;
-			const itemCommentStatic = DOM.create({ tag: DOM.Tags.Div, cls: `property-item-comment-static${!hasComment ? ' empty' : ''}`, innerHTML: hasComment ? CDUtils.nl2br(loadedCommentText) : 'Not commented yet...' });
-			itemCommentCn.push(itemCommentStatic);
-
-			if(type === ClassPage.PropertyType.Inherited) {
-				this.inheritedCommentsFields[`${property.nearestParent}:${property.key}`] = itemCommentStatic;
-				propertyItem.setAttribute(ClassPage.Attributes.DataPropertyInherited, 'true');
-			}
-
-			this.propertyItemElements[type === ClassPage.PropertyType.Statics ? `${ClassPage.__static__}${property.key}` : property.key] = propertyItem;
-			
-			if(isEditor) {
-				const itemCommentInput = DOM.create({ tag: DOM.Tags.Textarea, cls: 'property-item-comment-input hidden', attr: { 'placeholder': 'Not commented yet...'} }).setValue(CDUtils.br2nl(loadedCommentText));
-				itemCommentCn.push(itemCommentInput);
-				
-				if(type === ClassPage.PropertyType.Inherited) {
-					itemCommentInput.addClass(ClassPage.StyleClasses.Readonly).setAttribute('readonly', 'true');
-				} else {
-					itemCommentStatic.addClass(ClassPage.StyleClasses.Clickable);
-					itemCommentInput
-						.on(DOM.Events.KeyDown, this.delayedAdjustCommentInputHeight.bind(this))
-						.on(DOM.Events.Change, this.adjustCommentInputHeight.bind(this))
-						.on(DOM.Events.Cut, this.delayedAdjustCommentInputHeight.bind(this))
-						.on(DOM.Events.Paste, this.delayedAdjustCommentInputHeight.bind(this))
-						.on(DOM.Events.Drop, this.delayedAdjustCommentInputHeight.bind(this));
-
-					const onCommentSave = (e) => {
-						const commentContent = itemCommentInput.getValue();
-
-						if(commentContent === CDUtils.br2nl(itemCommentStatic.getValue()) || commentContent === '' && itemCommentStatic.hasClass('empty'))
-							return;
-
-						const propertyName = `${type === ClassPage.PropertyType.Statics ? ClassPage.__static__ : ''}${property.key}`;
-						const className = Class[ClassPage.ClassProperties.Name];
-						const classRoot = Class[ClassPage.ClassProperties.Root];
-
-						propertyItem.addClass('saving');
-						itemCommentInput.blur();
-
-						fetch('/updateComment', {
-							method: 'POST',
-							headers: {
-								'Content-Type': 'application/x-www-form-urlencoded'
-							},
-							body: new URLSearchParams({
-								'root': classRoot,
-								'class': className,
-								'property': propertyName,
-								'comment': commentContent
-							})
-						}).then((res) => {
-							if(res.status !== 202) {
-								propertyItem.removeClass('saving');
-								console.error(`Comment update failed (${res.status})`);
-							}
-						}).catch((e) => {
-								propertyItem.removeClass('saving');
-								console.error(`Comment update failed`);
-						});
-					};
-
-					const itemCommentOkButton = DOM.create({ tag: DOM.Tags.Div, cls: 'property-item-comment-button hidden', innerHTML: 'OK' }).on(DOM.Events.Click, onCommentSave);
-					itemCommentCn.push(itemCommentOkButton);
-
-					itemCommentInput.on(DOM.Events.KeyDown, (e) => {
-						if(e.key === DOM.Keys.Escape) {
-							const inputScrollTop = itemCommentInput.get().scrollTop;
-							itemCommentInput.switchClass(ClassPage.StyleClasses.Hidden);
-							itemCommentOkButton.switchClass(ClassPage.StyleClasses.Hidden);
-							itemCommentStatic.switchClass(ClassPage.StyleClasses.Hidden);
-							itemCommentStatic.get().scrollTop = inputScrollTop;
-							if(!itemCommentStatic.hasClass('empty'))
-								itemCommentInput.setValue(CDUtils.br2nl(itemCommentStatic.getValue()));
-						}
-						if(e.key === DOM.Keys.Enter && !e.shiftKey) {
-							onCommentSave();
-							e.preventDefault();
-						}
-					});
-
-					itemCommentStatic.on(DOM.Events.Click, (e) => {
-						if(CDElement.get(e.target).getTag() === DOM.Tags.A)
-							return;
-						itemCommentInput.switchClass(ClassPage.StyleClasses.Hidden);
-						itemCommentInput.focus();
-						itemCommentInput.style('height', `${Math.min(422, itemCommentStatic.get().scrollHeight + 2)}px`);
-						itemCommentInput.get().scrollTop = itemCommentStatic.get().scrollTop;
-						itemCommentOkButton.switchClass(ClassPage.StyleClasses.Hidden);
-						itemCommentStatic.switchClass(ClassPage.StyleClasses.Hidden);
-					});
-				}
-
-				DOM.create({ tag: DOM.Tags.Div, cls: 'property-item-saving-filler' }, propertyItem);
 			}
-
-			if(hasComment)
-				itemCommentCn.push(this.createCommentDateElement(loadedComment.timestamp, loadedComment.author));
-
-			DOM.create({ tag: DOM.Tags.Div, cls: 'property-item-comment', cn: itemCommentCn }, propertyItem);
+			const data = { type: property.type, nearestParent: property.nearestParent, static: isStatics, value: property.value };
+			this.createPropertyItem(property.key, data, type, propertiesList, isMethods, onMouseDown);
 		}
 
 		propertiesHeader.on(DOM.Events.Click, (e) => {
@@ -517,6 +389,21 @@ class ClassPage {
 		});
 	}
 
+	updateComment(classRoot, className, propertyName, commentContent) {
+		return fetch('/updateComment', {
+			method: 'POST',
+			headers: {
+				'Content-Type': 'application/x-www-form-urlencoded'
+			},
+			body: new URLSearchParams({
+				'root': classRoot,
+				'class': className,
+				'property': propertyName,
+				'comment': commentContent
+			})
+		});
+	}
+
 	createCommentDateElement(date, author) {
 		const commentDateText = DOM.create({ tag: DOM.Tags.Span, cls: 'property-item-comment-date-text', innerHTML: `Commented by <b>${author}</b> on: ` });
 		const commentDateDate = DOM.create({ tag: DOM.Tags.Span, cls: 'property-item-comment-date-date', innerHTML: CDUtils.dateFormatUTC(date, 3, 'D.M.Y, H:I:S') });
@@ -603,6 +490,147 @@ class ClassPage {
 		this.renderClassItems(Class[ClassPage.ClassProperties.MixedIn], mixedInElement);
 	}
 
+	renderInfo() {
+		const infoContainer = this.contentElements[ClassPage.TabNames.Info];
+		//infoContainer.append(DOM.create({ tag: DOM.Tags.Div, innerHTML: 'abc test' }));
+		const propertiesList = DOM.create({ tag: DOM.Tags.Div, cls: 'properties-list' }, infoContainer);
+		this.createPropertyItem(ClassPage.__self__, { value: '' }, ClassPage.PropertyType.ClassComment, propertiesList);
+	}
+
+	createPropertyItem(name, data, type, container, isMethod, onMouseDown) {
+		const key = data.static ? `${ClassPage.__static__}${name}` : name;
+		const propertyItem = DOM.create({ tag: DOM.Tags.Div, cls: 'property-item', attr: { [ClassPage.Attributes.DataPropertyName]: key, [ClassPage.Attributes.DataPropertyType]: data.type }}, container).on(DOM.Events.MouseDown, (e) => {
+			if(onMouseDown)
+				onMouseDown(e);
+		});
+
+		if(type !== ClassPage.PropertyType.ClassComment) {
+			const itemNameText = DOM.create({ tag: DOM.Tags.Span, innerHTML: isMethod ? 'Signature: ' : 'Name: ' });
+			const itemNameValue = DOM.create({ tag: DOM.Tags.Span, cls: 'property-item-name-span', innerHTML: isMethod ? data.value : name });
+			DOM.create({ tag: DOM.Tags.Div, cls: 'property-item-name', cn: [itemNameText, itemNameValue] }, propertyItem);
+			
+			if(type !== ClassPage.PropertyType.Dynamic && !isMethod) {
+				const itemTypeText = DOM.create({ tag: DOM.Tags.Span, innerHTML: 'Type: ' });
+				const itemTypeValue = DOM.create({ tag: DOM.Tags.Span, cls: 'property-item-type-span', innerHTML: data.type });
+				DOM.create({ tag: DOM.Tags.Div, cls: 'property-item-type', cn: [itemTypeText, itemTypeValue] }, propertyItem);
+			}
+
+			if(type !== ClassPage.PropertyType.Dynamic && !isMethod && data.type !== 'undefined') {
+				const itemValueText = DOM.create({ tag: DOM.Tags.Span, innerHTML: 'Default value: ' });
+				const itemValueValue = DOM.create({ tag: DOM.Tags.Span, cls: 'property-item-default-value-span', innerHTML: data.type === 'string' ? `'${data.value}'` : data.value });
+				DOM.create({ tag: DOM.Tags.Div, cls: 'property-item-default-value', cn: [itemValueText, itemValueValue] }, propertyItem);
+			}
+
+			if(type !== ClassPage.PropertyType.Dynamic && type !== ClassPage.PropertyType.Statics && type !== ClassPage.PropertyType.Base) {
+				const itemParentText = DOM.create({ tag: DOM.Tags.Span, innerHTML: 'Nearest parent: ' });
+				const itemParentValue = DOM.create({ tag: DOM.Tags.Span, cls: 'property-item-nearest-parent-span', innerHTML: data.nearestParent }).on('click', (e) => {
+					Url.goTo(`/class/${data.nearestParent}`);
+				});
+				DOM.create({ tag: DOM.Tags.Div, cls: 'property-item-nearest-parent', cn: [itemParentText, itemParentValue] }, propertyItem);
+				propertyItem.setAttribute(ClassPage.Attributes.DataPropertyParent, data.nearestParent);
+			}
+
+			if(type === ClassPage.PropertyType.Dynamic) {
+				propertyItem.setAttribute(ClassPage.Attributes.DataPropertyDynamic, 'true');
+			}
+		}
+
+		const itemCommentText = DOM.create({ tag: DOM.Tags.Div, innerHTML: 'Comment:' });
+		const itemCommentCn = [itemCommentText];
+
+		const loadedComment = Comments[type === ClassPage.PropertyType.Statics ? `${ClassPage.__static__}${name}` : name];
+		const loadedCommentText = loadedComment && typeof loadedComment === 'object' ? loadedComment.text : '';
+
+		const hasComment = loadedComment && typeof loadedComment === 'object' && loadedCommentText.length > 0;
+		const itemCommentStatic = DOM.create({ tag: DOM.Tags.Div, cls: `property-item-comment-static${!hasComment ? ' empty' : ''}`, innerHTML: hasComment ? CDUtils.nl2br(loadedCommentText) : 'Not commented yet...' });
+		itemCommentCn.push(itemCommentStatic);
+
+		if(type === ClassPage.PropertyType.Inherited) {
+			this.inheritedCommentsFields[`${data.nearestParent}:${name}`] = itemCommentStatic;
+			propertyItem.setAttribute(ClassPage.Attributes.DataPropertyInherited, 'true');
+		}
+
+		this.propertyItemElements[type === ClassPage.PropertyType.Statics ? `${ClassPage.__static__}${name}` : name] = propertyItem;
+		
+		if(isEditor) {
+			const itemCommentInput = DOM.create({ tag: DOM.Tags.Textarea, cls: 'property-item-comment-input hidden', attr: { 'placeholder': 'Not commented yet...'} }).setValue(CDUtils.br2nl(loadedCommentText));
+			itemCommentCn.push(itemCommentInput);
+			
+			if(type === ClassPage.PropertyType.Inherited) {
+				itemCommentInput.addClass(ClassPage.StyleClasses.Readonly).setAttribute('readonly', 'true');
+			} else {
+				itemCommentStatic.addClass(ClassPage.StyleClasses.Clickable);
+				itemCommentInput
+					.on(DOM.Events.KeyDown, this.delayedAdjustCommentInputHeight.bind(this))
+					.on(DOM.Events.Change, this.adjustCommentInputHeight.bind(this))
+					.on(DOM.Events.Cut, this.delayedAdjustCommentInputHeight.bind(this))
+					.on(DOM.Events.Paste, this.delayedAdjustCommentInputHeight.bind(this))
+					.on(DOM.Events.Drop, this.delayedAdjustCommentInputHeight.bind(this));
+
+				const onCommentSave = (e) => {
+					const commentContent = itemCommentInput.getValue();
+
+					if(commentContent === CDUtils.br2nl(itemCommentStatic.getValue()) || commentContent === '' && itemCommentStatic.hasClass('empty'))
+						return;
+
+					const propertyName = `${type === ClassPage.PropertyType.Statics ? ClassPage.__static__ : ''}${name}`;
+					const className = Class[ClassPage.ClassProperties.Name];
+					const classRoot = Class[ClassPage.ClassProperties.Root];
+
+					propertyItem.addClass('saving');
+					itemCommentInput.blur();
+
+					this.updateComment(classRoot, className, propertyName, commentContent).then((res) => {
+						if(res.status !== 202) {
+							propertyItem.removeClass('saving');
+							console.error(`Comment update failed (${res.status})`);
+						}
+					}).catch((e) => {
+							propertyItem.removeClass('saving');
+							console.error(`Comment update failed`);
+					});
+				};
+
+				const itemCommentOkButton = DOM.create({ tag: DOM.Tags.Div, cls: 'property-item-comment-button hidden', innerHTML: 'OK' }).on(DOM.Events.Click, onCommentSave);
+				itemCommentCn.push(itemCommentOkButton);
+
+				itemCommentInput.on(DOM.Events.KeyDown, (e) => {
+					if(e.key === DOM.Keys.Escape) {
+						const inputScrollTop = itemCommentInput.get().scrollTop;
+						itemCommentInput.switchClass(ClassPage.StyleClasses.Hidden);
+						itemCommentOkButton.switchClass(ClassPage.StyleClasses.Hidden);
+						itemCommentStatic.switchClass(ClassPage.StyleClasses.Hidden);
+						itemCommentStatic.get().scrollTop = inputScrollTop;
+						if(!itemCommentStatic.hasClass('empty'))
+							itemCommentInput.setValue(CDUtils.br2nl(itemCommentStatic.getValue()));
+					}
+					if(e.key === DOM.Keys.Enter && !e.shiftKey) {
+						onCommentSave();
+						e.preventDefault();
+					}
+				});
+
+				itemCommentStatic.on(DOM.Events.Click, (e) => {
+					if(CDElement.get(e.target).getTag() === DOM.Tags.A)
+						return;
+					itemCommentInput.switchClass(ClassPage.StyleClasses.Hidden);
+					itemCommentInput.focus();
+					itemCommentInput.style('height', `${Math.min(422, itemCommentStatic.get().scrollHeight + 2)}px`);
+					itemCommentInput.get().scrollTop = itemCommentStatic.get().scrollTop;
+					itemCommentOkButton.switchClass(ClassPage.StyleClasses.Hidden);
+					itemCommentStatic.switchClass(ClassPage.StyleClasses.Hidden);
+				});
+			}
+
+			DOM.create({ tag: DOM.Tags.Div, cls: 'property-item-saving-filler' }, propertyItem);
+		}
+
+		if(hasComment)
+			itemCommentCn.push(this.createCommentDateElement(loadedComment.timestamp, loadedComment.author));
+
+		DOM.create({ tag: DOM.Tags.Div, cls: 'property-item-comment', cn: itemCommentCn }, propertyItem);
+	}
+
 	renderFullSourcePrompt() {
 		const editorContent = this.contentElements[ClassPage.TabNames.Editor];
 
@@ -629,6 +657,9 @@ class ClassPage {
 	}
 
 	markContentInEditor() {
+		if(Class.isPackage)
+			return;
+
 		const staticsRange = this.getStaticsRange();
 
 		this.codeMirrorEditor.cmEachLine((lineHandle) => {
@@ -1035,8 +1066,8 @@ class ClassPage {
 		const tabElements = this.tabElements;
 		const contentElements = this.contentElements;
 
-		const selectedTab = tabElements[hashTab] || tabElements[ClassPage.TabNames.Editor];
-		const activeContent = contentElements[hashTab] || contentElements[ClassPage.TabNames.Editor];
+		const selectedTab = tabElements[hashTab] || (Class.isPackage ? tabElements[ClassPage.TabNames.Info] : tabElements[ClassPage.TabNames.Editor]);
+		const activeContent = contentElements[hashTab] || (Class.isPackage ? contentElements[ClassPage.TabNames.Info] : contentElements[ClassPage.TabNames.Editor]);
 
 		this.selectTab(selectedTab);
 		this.activateContent(activeContent);
@@ -1122,87 +1153,51 @@ class ClassPage {
 		this.statistics = Statistics.init(contributionElement, Statistics.Modes.CLASS);
 	}
 
-	/*	>>> Context menu | TODO: move to a completely independent module? */
-	showContextMenu(contextMenuType, target, pos) {
-		while(!target.hasClass(contextMenuType))
+	/*	>>> Context menu */
+	showContextMenu(target, pos) {
+		while(!target.hasClass('property-item'))
 			target = target.getParent();
 
-		switch(contextMenuType) {
-		case ClassPage.ContextMenuType.PropertyItem:
-			const propertyItemName = target.getAttribute(ClassPage.Attributes.DataPropertyName);
-			const propertyItemType = target.getAttribute(ClassPage.Attributes.DataPropertyType);
-			const propertyItemParent = target.getAttribute(ClassPage.Attributes.DataPropertyParent);
-			const propertyItemDynamic = target.getAttribute(ClassPage.Attributes.DataPropertyDynamic);
-			const propertyItemInhertied = target.getAttribute(ClassPage.Attributes.DataPropertyInherited);
-			const propertyVisualName = target.getFirstChild('.property-item-name-span').getValue();
-
-			if(propertyItemInhertied !== 'true') {
-				this.createContextMenuItem('ShowInEditor', 'Show in Editor', () => {
-					this.searchPropertyInEditor(propertyItemType === 'method', propertyItemDynamic === 'true', propertyItemName);
-				});
-			}
-			if(propertyItemParent != null) {
-				this.createContextMenuItem('MoveToParent', 'Move to parent', () => {
-					Url.goTo(`/class/${propertyItemParent}#${propertyItemType === 'method' ? 'Methods' : 'Properties'}:${propertyItemName}`);
-				});
-			}
-			if(isEditor && !target.getFirstChild('.property-item-comment-static').hasClass(ClassPage.StyleClasses.Hidden)) {
-				this.createContextMenuDelimiter();
-				this.createContextMenuItem('EditComment', 'Edit comment', () => {
-					target.getFirstChild('.property-item-comment-static').click();
-					target.getFirstChild('.property-item-comment-input').focus();
-				});
-			}
-			this.createContextMenuDelimiter();
-			this.createContextMenuItem('CopyLink', 'Copy link', () => {
-				DOM.copyToClipboard(`${Url.getFullPath()}#${propertyItemType === 'method' ? 'Methods' : 'Properties'}:${propertyItemName}`);
+		const contextMenu = this.contextMenu;
+
+		const propertyItemName = target.getAttribute(ClassPage.Attributes.DataPropertyName);
+		const propertyItemType = target.getAttribute(ClassPage.Attributes.DataPropertyType);
+		const propertyItemParent = target.getAttribute(ClassPage.Attributes.DataPropertyParent);
+		const propertyItemDynamic = target.getAttribute(ClassPage.Attributes.DataPropertyDynamic);
+		const propertyItemInhertied = target.getAttribute(ClassPage.Attributes.DataPropertyInherited);
+		const propertyVisualName = target.getFirstChild('.property-item-name-span').getValue();
+
+		if(propertyItemInhertied !== 'true') {
+			contextMenu.addItem('ShowInEditor', 'Show in Editor', () => {
+				this.searchPropertyInEditor(propertyItemType === 'method', propertyItemDynamic === 'true', propertyItemName);
 			});
-			this.createContextMenuDelimiter();
-			this.createContextMenuItem('CopyHtmlLink', 'Copy HTML link', () => {
-				let linkText = propertyVisualName;
-				if(propertyItemName.startsWith(ClassPage.__static__))
-						linkText = `${Class[ClassPage.ClassProperties.ShortName] || Class[ClassPage.ClassProperties.Name]}.${propertyVisualName}`;
-				DOM.copyToClipboard(`<a href="${Url.getPath()}#${propertyItemType === 'method' ? 'Methods' : 'Properties'}:${propertyItemName}">${linkText}</a>`);
+		}
+		if(propertyItemParent != null) {
+			contextMenu.addItem('MoveToParent', 'Move to parent', () => {
+				Url.goTo(`/class/${propertyItemParent}#${propertyItemType === 'method' ? 'Methods' : 'Properties'}:${propertyItemName}`);
 			});
-			break;
 		}
-
-		this.contextMenu.style('left', `${pos.x}px`).style('top', `${pos.y}px`);
-		this.contextMenu.removeClass(ClassPage.StyleClasses.Hidden);
-	}
-
-	createContextMenuItem(name, text, action) {
-		const itemAction = (e) => {
-			action(e);
-			this.hideContextMenu();
-		};
-		const item = DOM.create({ tag: DOM.Tags.Div, cls: 'context-menu-item', innerHTML: text, attr: { 'data-context-menu-item-name': name } }, this.contextMenu)
-			.on(DOM.Events.Click, itemAction);
-		this.contextMenuItems[name] = { item: item, action: itemAction };
-	}
-
-	createContextMenuDelimiter() {
-		DOM.create({ tag: DOM.Tags.Div, cls: 'context-menu-delimiter' }, this.contextMenu);
-	}
-
-	clearContextMenu() {
-		for(const item of this.contextMenu.getChildren()) {
-			const name = item.getAttribute('data-context-menu-item-name');
-			if(name) {
-				const action = this.contextMenuItems[name].action;
-				item.un(DOM.Events.Click, action);
-				this.contextMenuItems[name] = null;
-				delete this.contextMenuItems[name];
-			}
-			item.remove();
+		if(isEditor && !target.getFirstChild('.property-item-comment-static').hasClass(ClassPage.StyleClasses.Hidden)) {
+			contextMenu.addDelimiter();
+			contextMenu.addItem('EditComment', 'Edit comment', () => {
+				target.getFirstChild('.property-item-comment-static').click();
+				target.getFirstChild('.property-item-comment-input').focus();
+			});
 		}
-	}
+		contextMenu.addDelimiter();
+		contextMenu.addItem('CopyLink', 'Copy link', () => {
+			DOM.copyToClipboard(`${Url.getFullPath()}#${propertyItemType === 'method' ? 'Methods' : 'Properties'}:${propertyItemName}`);
+		});
+		contextMenu.addDelimiter();
+		contextMenu.addItem('CopyHtmlLink', 'Copy HTML link', () => {
+			let linkText = propertyVisualName;
+			if(propertyItemName.startsWith(ClassPage.__static__))
+					linkText = `${Class[ClassPage.ClassProperties.ShortName] || Class[ClassPage.ClassProperties.Name]}.${propertyVisualName}`;
+			DOM.copyToClipboard(`<a href="${Url.getPath()}#${propertyItemType === 'method' ? 'Methods' : 'Properties'}:${propertyItemName}">${linkText}</a>`);
+		});
 
-	hideContextMenu() {
-		this.contextMenu.addClass(ClassPage.StyleClasses.Hidden);
-		this.clearContextMenu();
+		contextMenu.show(pos);
 	}
-	
 	/* <<< Context menu */
 };
 
@@ -1211,6 +1206,8 @@ window_.on(DOM.Events.Load, (e) => {
 });
 
 window_.on(DOM.Events.KeyDown, (e) => {
+	if(Class.isPackage)
+		return;
 	if(window.page && e.key === DOM.Keys.Control)
 		window.page.codeMirrorEditorElement.addClass(ClassPage.StyleClasses.CtrlPressed);
 	if(window.page && e.key === DOM.Keys.Shift)
@@ -1218,6 +1215,8 @@ window_.on(DOM.Events.KeyDown, (e) => {
 });
 
 window_.on(DOM.Events.KeyUp, (e) => {
+	if(Class.isPackage)
+		return;
 	if(window.page && e.key === DOM.Keys.Control)
 		window.page.codeMirrorEditorElement.removeClass(ClassPage.StyleClasses.CtrlPressed);
 	if(window.page && e.key === DOM.Keys.Shift)
@@ -1227,20 +1226,4 @@ window_.on(DOM.Events.KeyUp, (e) => {
 window_.on(DOM.Events.HashChange, (e) => {
 	if(window.page)
 		window.page.applyHash();
-});
-
-window_.on(DOM.Events.MouseDown, (e) => {
-	if(window.page) {
-		let target = CDElement.get(e.target);
-
-		while(target != null && !target.hasClass('context-menu')) {
-			target = target.getParent();
-		}
-
-		if(target != null && target.hasClass('context-menu'))
-			return;
-
-		if(!window.page.contextMenu.hasClass(ClassPage.StyleClasses.Hidden))
-			window.page.hideContextMenu();
-	}
 });

+ 9 - 0
static/page/class/style.css

@@ -69,6 +69,15 @@
 	display: inline-block;
 }
 
+.right-header > .right-header-top > .class-name > .package-icon {
+	background: url(/img/folder.svg);
+	width: 30px;
+	height: 30px;
+	background-size: cover;
+	margin-right: 5px;
+	display: inline-block;
+}
+
 .right-header > .right-header-top > .display-mode-buttons {
 	display: flex;
 	flex-direction: row;

+ 2 - 41
static/style/style.css

@@ -215,7 +215,8 @@ body.page-not-found > .message-container > .page-not-found-image {
 
 .class-item.selected,
 .class-item:hover,
-.dir-item>.dir-name:hover {
+.dir-item>.dir-name:hover,
+.dir-item.selected>.dir-name {
 	background-color: rgba(0, 0, 0, 0.2);
 }
 
@@ -274,46 +275,6 @@ a, a:visited {
 
 /* <<< Class/Dir items */
 
-.context-menu {
-	position: absolute;
-	display: flex;
-	flex-direction: column;
-	z-index: 10000;
-	width: fit-content;
-	background-color: #312d2a;
-	border: 1px solid #d1d1d1;
-	box-shadow: 3px 3px 3px #0004;
-}
-
-.context-menu > .context-menu-item {
-	padding: 5px 10px;
-	user-select: none;
-	cursor: pointer;
-}
-
-.context-menu > .context-menu-item {
-	margin: 3px 0;
-}
-
-.context-menu > .context-menu-item:hover {
-	background-color: #ffffff40;
-}
-
-.context-menu > .context-menu-delimiter {
-	border-bottom: 1px solid #d1d1d1;
-	width: 100%;
-}
-
-.context-menu-overlay {
-	position: absolute;
-	top: 0;
-	left: 0;
-	right: 0;
-	bottom: 0;
-	margin: auto;
-	z-index: 9999;
-}
-
 .hidden {
 	display: none !important;
 	visibility: hidden !important;